diff --git a/docs/plans/2026-03-18-media3-android-auto-implementation.md b/docs/plans/2026-03-18-media3-android-auto-implementation.md new file mode 100644 index 0000000..61fd129 --- /dev/null +++ b/docs/plans/2026-03-18-media3-android-auto-implementation.md @@ -0,0 +1,820 @@ +# Media3 Migration + Android Auto Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace legacy MediaSessionCompat with Media3 MediaLibraryService to get proper system media notifications (with metadata + album art) and Android Auto browsing support. + +**Architecture:** A thin `RadioPlayerAdapter` implements Media3's `Player` interface as a facade over the existing custom audio engine. `RadioPlaybackService` converts from `LifecycleService` to `MediaLibraryService`, which auto-manages notifications and exposes a browse tree for Android Auto. The custom audio pipeline (OkHttp → IcyParser → MediaCodec → AudioTrack) is untouched. + +**Tech Stack:** Kotlin, Media3 1.6.0 (media3-session, media3-common), Room, Coroutines + +**Design doc:** `docs/plans/2026-03-18-media3-android-auto-design.md` + +--- + +### Task 1: Update Dependencies + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `app/build.gradle.kts` + +**Step 1: Add Media3 to version catalog** + +In `gradle/libs.versions.toml`, add version and libraries: + +```toml +# In [versions], add: +media3 = "1.6.0" + +# In [libraries], add: +media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } +media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } + +# In [libraries], remove: +# media-session = { group = "androidx.media", name = "media", version.ref = "media" } + +# In [versions], remove: +# media = "1.7.1" +``` + +**Step 2: Swap dependencies in build.gradle.kts** + +In `app/build.gradle.kts`, replace: + +```kotlin +// Remove: +implementation(libs.media.session) + +// Add: +implementation(libs.media3.session) +implementation(libs.media3.common) +``` + +Also remove `implementation(libs.lifecycle.service)` — `MediaLibraryService` does not extend `LifecycleService`. + +**Step 3: Verify build compiles (expect errors)** + +Run: `./gradlew :app:compileDebugKotlin 2>&1 | tail -20` + +Expected: Compilation errors in `RadioPlaybackService.kt` and `NotificationHelper.kt` because they reference `MediaSessionCompat` and `LifecycleService`. This is expected — we'll fix them in subsequent tasks. + +**Step 4: Commit** + +```bash +git add gradle/libs.versions.toml app/build.gradle.kts +git commit -m "chore: swap androidx.media for media3-session + media3-common" +``` + +--- + +### Task 2: Add Manifest + Automotive Config + +**Files:** +- Modify: `app/src/main/AndroidManifest.xml` +- Create: `app/src/main/res/xml/automotive_app.xml` + +**Step 1: Create automotive_app.xml** + +Create `app/src/main/res/xml/automotive_app.xml`: + +```xml + + + + +``` + +**Step 2: Update AndroidManifest.xml** + +Replace the `` block and add the `` inside ``: + +```xml + + + + + + + + +``` + +The service is now `exported="true"` so Android Auto can bind to it. The two intent-filter actions let both Media3 and legacy clients discover it. + +**Step 3: Commit** + +```bash +git add app/src/main/AndroidManifest.xml app/src/main/res/xml/automotive_app.xml +git commit -m "feat: add Android Auto automotive_app.xml and update service manifest" +``` + +--- + +### Task 3: Create RadioPlayerAdapter + +This is the thin Player facade. It does NOT produce audio — it only reports state and maps play/stop/pause commands to `RadioController`. + +**Files:** +- Create: `app/src/main/java/xyz/cottongin/radio247/service/RadioPlayerAdapter.kt` + +**Step 1: Create RadioPlayerAdapter** + +Create `app/src/main/java/xyz/cottongin/radio247/service/RadioPlayerAdapter.kt`. + +This class implements `androidx.media3.common.Player` by extending `androidx.media3.common.BasePlayer` (which provides default no-op implementations for most methods). We only override what matters for live radio. + +Key behaviors: +- Observes `RadioController.state` and translates to Media3 `Player.STATE_*` + `isPlaying` +- `play()` → calls `onPlay` callback (service handles actual play via controller) +- `stop()` → calls `onStop` callback +- `pause()` → calls `onStop` callback (radio can't pause) +- Holds a `MediaMetadata` that gets updated when ICY metadata changes +- Holds a single `MediaItem` representing the current station +- Notifies registered `Player.Listener` instances on state/metadata changes +- `getAvailableCommands()` returns only: `COMMAND_PLAY_PAUSE`, `COMMAND_STOP`, `COMMAND_GET_CURRENT_MEDIA_ITEM`, `COMMAND_GET_MEDIA_ITEMS_METADATA` +- `isCurrentMediaItemLive` returns `true` + +Important implementation notes: +- `BasePlayer` requires `seekToDefaultPosition()`, `seekTo(mediaItemIndex, positionMs)`, `getPlaybackParameters()`, `setPlaybackParameters()`, etc. — return defaults / no-ops for all of these. +- Must call `listeners.forEach { it.onXxx() }` when state changes, or use the `invalidateState()` helper if available. + +```kotlin +package xyz.cottongin.radio247.service + +import android.os.Looper +import androidx.media3.common.BasePlayer +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import xyz.cottongin.radio247.audio.IcyMetadata +import xyz.cottongin.radio247.data.model.Station + +class RadioPlayerAdapter( + private val onPlay: () -> Unit, + private val onStop: () -> Unit, +) : BasePlayer() { + + private val listeners = mutableListOf() + + private var _playbackState: Int = Player.STATE_IDLE + private var _playWhenReady: Boolean = false + private var _mediaMetadata: MediaMetadata = MediaMetadata.EMPTY + private var _currentMediaItem: MediaItem? = null + + override fun getApplicationLooper(): Looper = Looper.getMainLooper() + + override fun addListener(listener: Player.Listener) { listeners.add(listener) } + override fun removeListener(listener: Player.Listener) { listeners.remove(listener) } + + override fun getAvailableCommands(): Player.Commands { + return Player.Commands.Builder() + .add(Player.COMMAND_PLAY_PAUSE) + .add(Player.COMMAND_STOP) + .add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) + .add(Player.COMMAND_GET_MEDIA_ITEMS_METADATA) + .build() + } + + override fun getPlaybackState(): Int = _playbackState + override fun getPlayWhenReady(): Boolean = _playWhenReady + override fun isPlaying(): Boolean = _playbackState == Player.STATE_READY && _playWhenReady + + override fun getMediaMetadata(): MediaMetadata = _mediaMetadata + override fun getCurrentMediaItem(): MediaItem? = _currentMediaItem + + override fun play() { + _playWhenReady = true + onPlay() + } + + override fun pause() { + _playWhenReady = false + onStop() + } + + override fun stop() { + _playWhenReady = false + onStop() + } + + override fun prepare() { /* no-op for live radio */ } + override fun setPlayWhenReady(playWhenReady: Boolean) { + if (playWhenReady) play() else pause() + } + + override fun release() { + listeners.clear() + } + + override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { /* no-op */ } + + override fun getRepeatMode(): Int = Player.REPEAT_MODE_OFF + override fun setRepeatMode(repeatMode: Int) { /* no-op */ } + + override fun getShuffleModeEnabled(): Boolean = false + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { /* no-op */ } + + override fun seekToDefaultPosition() { /* no-op */ } + override fun seekToDefaultPosition(mediaItemIndex: Int) { /* no-op */ } + override fun seekTo(positionMs: Long) { /* no-op */ } + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { /* no-op */ } + + override fun getCurrentTimeline(): Timeline = Timeline.EMPTY + override fun getCurrentMediaItemIndex(): Int = 0 + override fun getContentPosition(): Long = 0 + override fun getContentDuration(): Long = -1 // C.TIME_UNSET + override fun getCurrentPosition(): Long = 0 + override fun getDuration(): Long = -1 // C.TIME_UNSET + override fun getBufferedPosition(): Long = 0 + override fun getTotalBufferedDuration(): Long = 0 + override fun isCurrentMediaItemLive(): Boolean = true + + // --- State update methods called by RadioPlaybackService --- + + fun updatePlaybackState(state: PlaybackState) { + val (newPlayerState, newPlayWhenReady) = when (state) { + is PlaybackState.Idle -> Player.STATE_IDLE to false + is PlaybackState.Connecting -> Player.STATE_BUFFERING to true + is PlaybackState.Playing -> Player.STATE_READY to true + is PlaybackState.Paused -> Player.STATE_READY to false + is PlaybackState.Reconnecting -> Player.STATE_BUFFERING to true + } + + val stateChanged = newPlayerState != _playbackState + val playWhenReadyChanged = newPlayWhenReady != _playWhenReady + _playbackState = newPlayerState + _playWhenReady = newPlayWhenReady + + if (stateChanged || playWhenReadyChanged) { + listeners.forEach { listener -> + if (playWhenReadyChanged) { + listener.onPlayWhenReadyChanged( + newPlayWhenReady, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + ) + } + if (stateChanged) { + listener.onPlaybackStateChanged(newPlayerState) + } + listener.onIsPlayingChanged(isPlaying) + } + } + } + + fun updateStation(station: Station) { + _currentMediaItem = MediaItem.Builder() + .setMediaId("station:${station.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(station.name) + .setIsPlayable(true) + .build() + ) + .build() + listeners.forEach { it.onMediaItemTransition(_currentMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) } + } + + fun updateMetadata(station: Station, metadata: IcyMetadata?, artworkUri: android.net.Uri? = null) { + _mediaMetadata = MediaMetadata.Builder() + .setStation(station.name) + .setTitle(metadata?.title ?: station.name) + .setArtist(metadata?.artist) + .setArtworkUri(artworkUri) + .setIsPlayable(true) + .build() + listeners.forEach { it.onMediaMetadataChanged(_mediaMetadata) } + } +} +``` + +**Step 2: Verify it compiles** + +Run: `./gradlew :app:compileDebugKotlin 2>&1 | tail -20` + +Expected: Still fails on `RadioPlaybackService` and `NotificationHelper` (they still use old APIs). But `RadioPlayerAdapter.kt` itself should have no errors. If there are `BasePlayer` abstract method errors, add the missing no-op overrides. + +**Step 3: Commit** + +```bash +git add app/src/main/java/xyz/cottongin/radio247/service/RadioPlayerAdapter.kt +git commit -m "feat: add RadioPlayerAdapter — thin Player facade for Media3" +``` + +--- + +### Task 4: Rewrite RadioPlaybackService + +This is the largest task. Convert from `LifecycleService` + manual notifications to `MediaLibraryService` with `MediaLibrarySession`. + +**Files:** +- Modify: `app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt` + +**Step 1: Change the class signature and imports** + +Replace the superclass and imports. The service now extends `MediaLibraryService()`. + +Remove all imports of: +- `android.support.v4.media.session.MediaSessionCompat` +- `androidx.lifecycle.LifecycleService` + +Add imports for: +- `androidx.media3.session.MediaLibraryService` +- `androidx.media3.session.MediaLibraryService.MediaLibrarySession` +- `androidx.media3.session.MediaSession` +- `androidx.media3.session.SessionCommand` +- `androidx.media3.session.SessionResult` +- `androidx.media3.session.CommandButton` +- `androidx.media3.common.MediaItem` +- `androidx.media3.common.MediaMetadata` +- `android.os.Bundle` +- `com.google.common.util.concurrent.Futures` +- `com.google.common.util.concurrent.ListenableFuture` + +Change class declaration: + +```kotlin +class RadioPlaybackService : MediaLibraryService() { +``` + +**Step 2: Replace mediaSession and notificationHelper fields** + +Remove: +- `private val notificationHelper = NotificationHelper(this)` +- `private var mediaSession: MediaSessionCompat? = null` + +Add: +- `private var mediaSession: MediaLibrarySession? = null` +- `private var playerAdapter: RadioPlayerAdapter? = null` +- `private val seekToLiveCommand = SessionCommand("SEEK_TO_LIVE", Bundle.EMPTY)` + +**Step 3: Implement onGetSession()** + +This is required by `MediaLibraryService`: + +```kotlin +override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? { + return mediaSession +} +``` + +**Step 4: Rewrite ensureMediaSession()** + +Replace the old `ensureMediaSession()` that created a `MediaSessionCompat` with one that creates a `MediaLibrarySession`: + +```kotlin +private fun ensureMediaSession() { + if (mediaSession != null) return + + val adapter = RadioPlayerAdapter( + onPlay = { + val state = controller.state.value + val station = when (state) { + is PlaybackState.Playing -> state.station + is PlaybackState.Paused -> state.station + is PlaybackState.Connecting -> state.station + is PlaybackState.Reconnecting -> state.station + is PlaybackState.Idle -> null + } + station?.let { controller.play(it) } + }, + onStop = { controller.stop() } + ) + playerAdapter = adapter + + val sessionActivityIntent = packageManager + .getLaunchIntentForPackage(packageName) + ?.let { intent -> + PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + val seekToLiveButton = CommandButton.Builder(CommandButton.ICON_SKIP_FORWARD) + .setDisplayName("Live") + .setSessionCommand(seekToLiveCommand) + .build() + + mediaSession = MediaLibrarySession.Builder(this, adapter, LibrarySessionCallback()) + .setSessionActivity(sessionActivityIntent!!) + .setCustomLayout(listOf(seekToLiveButton)) + .build() +} +``` + +**Step 5: Implement LibrarySessionCallback** + +This inner class handles the Android Auto browse tree and custom commands: + +```kotlin +private inner class LibrarySessionCallback : MediaLibrarySession.Callback { + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + val root = MediaItem.Builder() + .setMediaId("root") + .setMediaMetadata( + MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build() + return Futures.immediateFuture(LibraryResult.ofItem(root, params)) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val deferred = serviceScope.launch(Dispatchers.IO) { + // resolved in the coroutine below + } + // Use a settable future since we need coroutines + val future = androidx.concurrent.futures.ResolvableFuture.create>>() + serviceScope.launch { + try { + val items = when { + parentId == "root" -> buildRootChildren() + parentId == "unsorted" -> buildUnsortedStations() + parentId.startsWith("playlist:") -> { + val playlistId = parentId.removePrefix("playlist:").toLong() + buildPlaylistStations(playlistId) + } + else -> emptyList() + } + future.set(LibraryResult.ofItemList(items, params)) + } catch (e: Exception) { + future.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN)) + } + } + return future + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + if (customCommand.customAction == "SEEK_TO_LIVE") { + handleSeekLive() + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + return super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + val sessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS + .buildUpon() + .add(seekToLiveCommand) + .build() + return MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(sessionCommands) + .build() + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList + ): ListenableFuture> { + // When a station is selected in Android Auto, start playback + val item = mediaItems.firstOrNull() ?: return Futures.immediateFuture(emptyList()) + val mediaId = item.mediaId + if (mediaId.startsWith("station:")) { + val stationId = mediaId.removePrefix("station:").toLongOrNull() + if (stationId != null) { + serviceScope.launch { + val station = stationDao.getStationById(stationId) + station?.let { this@RadioPlaybackService.controller.play(it) } + } + } + } + return Futures.immediateFuture(mediaItems) + } +} +``` + +**Step 6: Add browse tree helper methods** + +```kotlin +private suspend fun buildRootChildren(): List { + val items = mutableListOf() + val playlists = database.playlistDao().getAllPlaylists().first() + for (playlist in playlists) { + items.add( + MediaItem.Builder() + .setMediaId("playlist:${playlist.id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(playlist.name) + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + .build() + ) + .build() + ) + } + val unsorted = stationDao.getUnsortedStations().first() + if (unsorted.isNotEmpty()) { + items.add( + MediaItem.Builder() + .setMediaId("unsorted") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle("Unsorted") + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + .build() + ) + .build() + ) + } + return items +} + +private suspend fun buildPlaylistStations(playlistId: Long): List { + return stationDao.getStationsByPlaylist(playlistId).first().map { it.toMediaItem() } +} + +private suspend fun buildUnsortedStations(): List { + return stationDao.getUnsortedStations().first().map { it.toMediaItem() } +} + +private fun Station.toMediaItem(): MediaItem { + return MediaItem.Builder() + .setMediaId("station:${id}") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(name) + .setArtworkUri(defaultArtworkUrl?.let { android.net.Uri.parse(it) }) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build() + ) + .build() +} +``` + +**Step 7: Remove manual notification methods** + +Delete these methods entirely: +- `startForegroundWithPlaceholder()` — Media3 handles foreground +- `updateNotification()` — Media3 auto-generates from session state + +Replace all calls to `updateNotification(station, metadata, isReconnecting)` with calls to update the player adapter's state/metadata instead: + +```kotlin +// Where you previously called: updateNotification(station, null, isReconnecting = false) +// Now call: +playerAdapter?.updateStation(station) +playerAdapter?.updatePlaybackState(controller.state.value) +``` + +When ICY metadata arrives: +```kotlin +// Where you previously called: updateNotification(station, event.metadata, false) +// Now call: +playerAdapter?.updateMetadata(station, event.metadata) +``` + +**Step 8: Update startForeground call at the start of playback** + +The initial `startForegroundWithPlaceholder(station)` call needs to be replaced. `MediaLibraryService` manages foreground state, but we still need to ensure the adapter and session exist before the service tries to go foreground: + +```kotlin +// In handlePlay(), replace startForegroundWithPlaceholder(station) with: +ensureMediaSession() +playerAdapter?.updateStation(station) +playerAdapter?.updatePlaybackState(PlaybackState.Connecting(station)) +``` + +**Step 9: Remove onBind returning null** + +Delete the `onBind` override — `MediaLibraryService` provides its own binding. Also remove the `super.onStartCommand()` call if present (it was needed for `LifecycleService` but not `MediaLibraryService`). + +**Step 10: Update cleanupResources()** + +Replace `mediaSession?.release()` with: +```kotlin +mediaSession?.release() +mediaSession = null +playerAdapter?.release() +playerAdapter = null +``` + +**Step 11: Update handlePlay finally block** + +In the `finally` block of `handlePlay()`, when transitioning to `Idle`, update the adapter: +```kotlin +playerAdapter?.updatePlaybackState(PlaybackState.Idle) +``` + +And remove the `stopForeground(Service.STOP_FOREGROUND_REMOVE)` call — `MediaLibraryService` handles this. + +**Step 12: Update onCreate — remove notificationHelper.createChannel()** + +`MediaLibraryService` with the default notification provider manages its own channel. Remove the `notificationHelper.createChannel()` call from `onCreate()`. + +**Step 13: Album art on metadata change** + +In `awaitEngine()`, when `AudioEngineEvent.MetadataChanged` fires, resolve artwork and pass it to the adapter: + +```kotlin +is AudioEngineEvent.MetadataChanged -> { + currentMetadata = event.metadata + val playingState = controller.state.value + if (playingState is PlaybackState.Playing) { + transition(playingState.copy(metadata = event.metadata)) + } + persistMetadataSnapshot(station.id, event.metadata) + // Resolve artwork and update adapter + serviceScope.launch { + val artUrl = app.albumArtResolver.resolve( + artist = event.metadata.artist, + title = event.metadata.title, + icyStreamUrl = event.metadata.streamUrl, + stationArtworkUrl = station.defaultArtworkUrl + ) + val artUri = artUrl?.let { android.net.Uri.parse(it) } + playerAdapter?.updateMetadata(station, event.metadata, artUri) + } +} +``` + +**Step 14: Verify build** + +Run: `./gradlew :app:compileDebugKotlin 2>&1 | tail -40` + +Expected: Should compile (NotificationHelper still exists but is unused). Fix any compilation errors — common issues will be missing imports or `BasePlayer` abstract methods needing overrides. + +**Step 15: Commit** + +```bash +git add app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt +git commit -m "feat: rewrite RadioPlaybackService as MediaLibraryService with browse tree" +``` + +--- + +### Task 5: Delete NotificationHelper + +**Files:** +- Delete: `app/src/main/java/xyz/cottongin/radio247/service/NotificationHelper.kt` + +**Step 1: Delete the file** + +Remove `NotificationHelper.kt`. It's fully replaced by Media3's automatic notification management. + +**Step 2: Verify no remaining references** + +Search for `NotificationHelper` in the codebase. It should only appear in import cleanup. + +Run: `rg "NotificationHelper" --type kotlin` + +Expected: Zero results. + +**Step 3: Verify build** + +Run: `./gradlew :app:compileDebugKotlin 2>&1 | tail -20` + +Expected: Clean compilation. + +**Step 4: Commit** + +```bash +git add -A +git commit -m "refactor: remove NotificationHelper — Media3 handles notifications" +``` + +--- + +### Task 6: Update RadioController (if needed) + +**Files:** +- Modify: `app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt` + +**Step 1: Check if RadioController needs changes** + +`RadioController.play()` calls `application.startForegroundService(intent)`. With `MediaLibraryService`, the service is typically started via binding (Android Auto / MediaController) or via `startForegroundService` (from the app's UI). The current intent-based approach should still work because `MediaLibraryService` extends `Service` and `onStartCommand` still gets called. + +Verify that `RadioController` still compiles and functions — the intent actions (`ACTION_PLAY`, `ACTION_STOP`, etc.) are still handled by `onStartCommand` in the rewritten service. + +If `startForegroundService` causes issues (Media3 expects to manage foreground state), switch to `startService` for non-play actions and keep `startForegroundService` for play: + +```kotlin +// In stop(), pause(), seekToLive() — use startService instead of startForegroundService +application.startService(intent) +``` + +This is already the case in the current code, so no changes should be needed. + +**Step 2: Commit (only if changes were made)** + +```bash +git add app/src/main/java/xyz/cottongin/radio247/service/RadioController.kt +git commit -m "refactor: adjust RadioController for MediaLibraryService compatibility" +``` + +--- + +### Task 7: Full Build + Smoke Test + +**Files:** None (verification only) + +**Step 1: Clean build** + +Run: `./gradlew clean :app:assembleDebug 2>&1 | tail -30` + +Expected: BUILD SUCCESSFUL + +**Step 2: Run existing tests** + +Run: `./gradlew :app:testDebugUnitTest 2>&1 | tail -30` + +Expected: All existing tests pass. If any tests reference `NotificationHelper` or `MediaSessionCompat`, update them. + +**Step 3: Install and manual test** + +If a device/emulator is connected: + +Run: `./gradlew :app:installDebug` + +Manual verification checklist: +- [ ] App launches +- [ ] Selecting a station starts playback +- [ ] System notification appears with station name + track info +- [ ] Album art appears in notification (when resolved) +- [ ] Play/Stop button works in notification +- [ ] "Live" custom button appears in notification +- [ ] Lockscreen shows media controls +- [ ] Stopping playback dismisses notification + +Android Auto verification (requires Android Auto Desktop Head Unit or car): +- [ ] App appears in Android Auto media app list +- [ ] Browsing shows playlists +- [ ] Tapping a playlist shows stations +- [ ] Tapping a station starts playback +- [ ] Now Playing screen shows station name + metadata + +**Step 4: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix: address build/test issues from Media3 migration" +``` + +--- + +### Task 8: Notification Channel Cleanup + +**Files:** +- Modify: `app/src/main/java/xyz/cottongin/radio247/service/RadioPlaybackService.kt` (if needed) + +**Step 1: Verify notification channel** + +Media3's `DefaultMediaNotificationProvider` creates its own notification channel. The old `"radio_playback"` channel from `NotificationHelper` may linger on devices that had the old version installed. This is cosmetic — Android handles orphaned channels gracefully. + +If you want to customize the channel name/importance, override `MediaLibraryService.onUpdateNotification()` or provide a custom `MediaNotification.Provider`. For now, the default is fine (importance defaults to LOW for media sessions). + +**Step 2: Commit (only if changes)** + +No commit needed unless customization was added. + +--- + +## Summary of New/Changed/Deleted Files + +| File | Action | +|---|---| +| `gradle/libs.versions.toml` | Modified — add media3, remove old media | +| `app/build.gradle.kts` | Modified — swap deps | +| `app/src/main/AndroidManifest.xml` | Modified — exported service + Auto meta-data | +| `app/src/main/res/xml/automotive_app.xml` | Created | +| `app/src/main/java/.../service/RadioPlayerAdapter.kt` | Created | +| `app/src/main/java/.../service/RadioPlaybackService.kt` | Major rewrite | +| `app/src/main/java/.../service/NotificationHelper.kt` | Deleted | +| `app/src/main/java/.../service/RadioController.kt` | Possibly minor tweaks |