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() }