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
This commit is contained in:
820
docs/plans/2026-03-18-media3-android-auto-implementation.md
Normal file
820
docs/plans/2026-03-18-media3-android-auto-implementation.md
Normal file
@@ -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
|
||||||
|
<?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>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<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**
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```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<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**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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 |
|
||||||
Reference in New Issue
Block a user