8 tasks: deps, manifest, PlayerAdapter, service rewrite, NotificationHelper deletion, controller check, build/test, cleanup. Made-with: Cursor
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.stateand translates to Media3Player.STATE_*+isPlaying play()→ callsonPlaycallback (service handles actual play via controller)stop()→ callsonStopcallbackpause()→ callsonStopcallback (radio can't pause)- Holds a
MediaMetadatathat gets updated when ICY metadata changes - Holds a single
MediaItemrepresenting the current station - Notifies registered
Player.Listenerinstances on state/metadata changes getAvailableCommands()returns only:COMMAND_PLAY_PAUSE,COMMAND_STOP,COMMAND_GET_CURRENT_MEDIA_ITEM,COMMAND_GET_MEDIA_ITEMS_METADATAisCurrentMediaItemLivereturnstrue
Important implementation notes:
BasePlayerrequiresseekToDefaultPosition(),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 theinvalidateState()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.MediaSessionCompatandroidx.lifecycle.LifecycleService
Add imports for:
androidx.media3.session.MediaLibraryServiceandroidx.media3.session.MediaLibraryService.MediaLibrarySessionandroidx.media3.session.MediaSessionandroidx.media3.session.SessionCommandandroidx.media3.session.SessionResultandroidx.media3.session.CommandButtonandroidx.media3.common.MediaItemandroidx.media3.common.MediaMetadataandroid.os.Bundlecom.google.common.util.concurrent.Futurescom.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? = nullprivate var playerAdapter: RadioPlayerAdapter? = nullprivate 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 foregroundupdateNotification()— 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 |