Files
Android-247-Radio/docs/plans/2026-03-18-media3-android-auto-implementation.md
cottongin da754b3874 Add implementation plan for Media3 migration + Android Auto
8 tasks: deps, manifest, PlayerAdapter, service rewrite,
NotificationHelper deletion, controller check, build/test, cleanup.

Made-with: Cursor
2026-03-18 05:53:48 -04:00

29 KiB

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:

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

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

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 version="1.0" encoding="utf-8"?>
<automotiveApp>
    <uses name="media"/>
</automotiveApp>

Step 2: Update AndroidManifest.xml

Replace the <service> block and add the <meta-data> inside <application>:

<service
    android:name=".service.RadioPlaybackService"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback">
    <intent-filter>
        <action android:name="androidx.media3.session.MediaLibraryService"/>
        <action android:name="android.media.browse.MediaBrowserService"/>
    </intent-filter>
</service>

<meta-data
    android:name="com.google.android.gms.car.application"
    android:resource="@xml/automotive_app" />

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

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.
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<Player.Listener>()

    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

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:

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:

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:

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:

private inner class LibrarySessionCallback : MediaLibrarySession.Callback {

    override fun onGetLibraryRoot(
        session: MediaLibrarySession,
        browser: MediaSession.ControllerInfo,
        params: LibraryParams?
    ): ListenableFuture<LibraryResult<MediaItem>> {
        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<LibraryResult<ImmutableList<MediaItem>>> {
        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<LibraryResult<ImmutableList<MediaItem>>>()
        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<SessionResult> {
        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<MediaItem>
    ): ListenableFuture<List<MediaItem>> {
        // 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

private suspend fun buildRootChildren(): List<MediaItem> {
    val items = mutableListOf<MediaItem>()
    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<MediaItem> {
    return stationDao.getStationsByPlaylist(playlistId).first().map { it.toMediaItem() }
}

private suspend fun buildUnsortedStations(): List<MediaItem> {
    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:

// Where you previously called: updateNotification(station, null, isReconnecting = false)
// Now call:
playerAdapter?.updateStation(station)
playerAdapter?.updatePlaybackState(controller.state.value)

When ICY metadata arrives:

// 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:

// 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:

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:

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:

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

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

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:

// 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)

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

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