# 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 |