diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml
index 5f40125de..4a049d1d6 100644
--- a/wear/src/main/AndroidManifest.xml
+++ b/wear/src/main/AndroidManifest.xml
@@ -6,6 +6,13 @@
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= _localPlayerState.asStateFlow()
@@ -109,7 +116,6 @@ class WearLocalPlayerRepository @Inject constructor(
companion object {
private const val TAG = "WearLocalPlayer"
private const val POSITION_UPDATE_INTERVAL_MS = 1000L
- private const val MEDIA_SESSION_ID = "wear-local-playback"
}
init {
@@ -131,45 +137,57 @@ class WearLocalPlayerRepository @Inject constructor(
}
}
- private fun getOrCreatePlayer(): ExoPlayer {
- return exoPlayer ?: ExoPlayer.Builder(application).build().also { player ->
- exoPlayer = player
- player.setAudioAttributes(
- AudioAttributes.Builder()
- .setUsage(androidx.media3.common.C.USAGE_MEDIA)
- .setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC)
- .build(),
- true,
- )
- player.setHandleAudioBecomingNoisy(true)
- ensureMediaSession(player)
- player.addListener(object : Player.Listener {
- override fun onPlaybackStateChanged(playbackState: Int) {
- updateState()
- if (playbackState == Player.STATE_ENDED) {
- stopPositionUpdates()
- }
- }
+ private val playerListener = object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ updateState()
+ if (playbackState == Player.STATE_ENDED) {
+ stopPositionUpdates()
+ }
+ }
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- updateState()
- if (isPlaying) startPositionUpdates() else stopPositionUpdates()
- }
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ updateState()
+ if (isPlaying) startPositionUpdates() else stopPositionUpdates()
+ }
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- updateState()
- }
- })
- Timber.tag(TAG).d("ExoPlayer created")
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ updateState()
}
}
- private fun ensureMediaSession(player: ExoPlayer) {
- if (mediaSession != null) return
-
- mediaSession = MediaSession.Builder(application, player)
- .setId(MEDIA_SESSION_ID)
- .build()
+ /**
+ * Connect to (and implicitly start) [WearPlaybackService], returning a [MediaController] that
+ * drives its ExoPlayer. Playback lives inside that MediaSessionService so Android keeps it alive
+ * as a foreground "mediaPlayback" service while audio plays — otherwise Wear OS reaps the
+ * background process after a few minutes and playback dies silently.
+ */
+ private suspend fun getOrConnectController(): MediaController {
+ mediaController?.let { return it }
+ return withContext(Dispatchers.Main) {
+ mediaController?.let { return@withContext it }
+ val token = SessionToken(
+ application,
+ ComponentName(application, WearPlaybackService::class.java),
+ )
+ val controller = suspendCancellableCoroutine { continuation ->
+ val future = MediaController.Builder(application, token).buildAsync()
+ future.addListener(
+ {
+ try {
+ continuation.resume(future.get())
+ } catch (e: Exception) {
+ continuation.resumeWithException(e)
+ }
+ },
+ ContextCompat.getMainExecutor(application),
+ )
+ continuation.invokeOnCancellation { MediaController.releaseFuture(future) }
+ }
+ mediaController = controller
+ controller.addListener(playerListener)
+ Timber.tag(TAG).d("MediaController connected to WearPlaybackService")
+ controller
+ }
}
/**
@@ -279,7 +297,12 @@ class WearLocalPlayerRepository @Inject constructor(
transientCleanupPaths: Set = emptySet(),
) {
withContext(Dispatchers.Main) {
- val player = getOrCreatePlayer()
+ val player = try {
+ getOrConnectController()
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to connect to WearPlaybackService")
+ return@withContext
+ }
if (this@WearLocalPlayerRepository.transientCleanupPaths.isNotEmpty()) {
player.stop()
}
@@ -306,6 +329,14 @@ class WearLocalPlayerRepository @Inject constructor(
MediaItem.Builder()
.setMediaId(song.songId)
.setUri(song.uri)
+ // A MediaController drops localConfiguration (the URI) when items cross the
+ // binder to the service, so stash it in requestMetadata for the service's
+ // MediaSession.Callback to restore. See WearPlaybackService.
+ .setRequestMetadata(
+ MediaItem.RequestMetadata.Builder()
+ .setMediaUri(song.uri)
+ .build()
+ )
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(song.title)
@@ -334,11 +365,11 @@ class WearLocalPlayerRepository @Inject constructor(
}
fun play() {
- exoPlayer?.play()
+ mediaController?.play()
}
fun togglePlayPause() {
- val player = exoPlayer ?: return
+ val player = mediaController ?: return
if (player.isPlaying) {
player.pause()
} else {
@@ -347,31 +378,31 @@ class WearLocalPlayerRepository @Inject constructor(
}
fun pause() {
- exoPlayer?.pause()
+ mediaController?.pause()
}
fun next() {
- val player = exoPlayer ?: return
+ val player = mediaController ?: return
if (player.hasNextMediaItem()) {
player.seekToNext()
}
}
fun previous() {
- val player = exoPlayer ?: return
+ val player = mediaController ?: return
if (player.hasPreviousMediaItem()) {
player.seekToPrevious()
}
}
fun seekTo(positionMs: Long) {
- exoPlayer?.seekTo(positionMs)
+ mediaController?.seekTo(positionMs)
}
fun toggleShuffle() {
scope.launch {
withContext(Dispatchers.Main) {
- val player = exoPlayer ?: return@withContext
+ val player = mediaController ?: return@withContext
player.shuffleModeEnabled = !player.shuffleModeEnabled
updateState()
}
@@ -381,7 +412,7 @@ class WearLocalPlayerRepository @Inject constructor(
fun cycleRepeat() {
scope.launch {
withContext(Dispatchers.Main) {
- val player = exoPlayer ?: return@withContext
+ val player = mediaController ?: return@withContext
player.repeatMode = when (player.repeatMode) {
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
@@ -395,7 +426,7 @@ class WearLocalPlayerRepository @Inject constructor(
fun playQueueIndex(index: Int) {
scope.launch {
withContext(Dispatchers.Main) {
- val player = exoPlayer ?: return@withContext
+ val player = mediaController ?: return@withContext
if (index !in 0 until player.mediaItemCount) return@withContext
player.seekToDefaultPosition(index)
@@ -413,7 +444,7 @@ class WearLocalPlayerRepository @Inject constructor(
val queueIndex = currentQueueSongIds.indexOf(songId)
if (queueIndex == -1) return@withContext
- val player = exoPlayer
+ val player = mediaController
if (player == null || currentQueueSongIds.size <= 1) {
release()
return@withContext
@@ -441,10 +472,19 @@ class WearLocalPlayerRepository @Inject constructor(
*/
fun release() {
stopPositionUpdates()
- mediaSession?.release()
- mediaSession = null
- exoPlayer?.release()
- exoPlayer = null
+ mediaController?.let { controller ->
+ controller.removeListener(playerListener)
+ runCatching {
+ controller.stop()
+ controller.clearMediaItems()
+ }
+ controller.release()
+ }
+ mediaController = null
+ // Tear down the foreground service so its media notification clears immediately.
+ runCatching {
+ application.stopService(Intent(application, WearPlaybackService::class.java))
+ }
clearTransientPlaybackArtifacts()
_isLocalPlaybackActive.value = false
_localPlayerState.value = WearLocalPlayerState()
@@ -457,11 +497,11 @@ class WearLocalPlayerRepository @Inject constructor(
currentQueueItemsById = emptyMap()
lastPaletteSongId = ""
lastArtworkSongId = ""
- Timber.tag(TAG).d("ExoPlayer released")
+ Timber.tag(TAG).d("MediaController released, WearPlaybackService stopped")
}
private fun updateState() {
- val player = exoPlayer ?: return
+ val player = mediaController ?: return
val currentItem = player.currentMediaItem
val currentLocalSong = currentItem?.mediaId?.let(currentQueueSongsById::get)
_localPlayerState.value = WearLocalPlayerState(
@@ -516,8 +556,8 @@ class WearLocalPlayerRepository @Inject constructor(
}
private fun updateQueueState(currentIndex: Int? = null) {
- val player = exoPlayer
- val rawCurrentIndex = currentIndex ?: exoPlayer?.currentMediaItemIndex ?: -1
+ val player = mediaController
+ val rawCurrentIndex = currentIndex ?: mediaController?.currentMediaItemIndex ?: -1
val visibleQueueIndices = when {
player == null -> {
if (rawCurrentIndex in currentQueueSongIds.indices) {
@@ -577,7 +617,7 @@ class WearLocalPlayerRepository @Inject constructor(
)
}
- private fun buildVisibleQueueIndices(player: ExoPlayer, currentIndex: Int): List {
+ private fun buildVisibleQueueIndices(player: Player, currentIndex: Int): List {
if (currentIndex !in 0 until player.mediaItemCount) {
return (0 until player.mediaItemCount).toList()
}