diff --git a/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt b/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt index 3f089022..8d950d22 100644 --- a/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt +++ b/media/media3/src/main/java/com/maxrave/media3/exoplayer/CrossfadeExoPlayerAdapter.kt @@ -1465,9 +1465,7 @@ internal class CrossfadeExoPlayerAdapter( startPositionMs: Long, shouldPlay: Boolean, ) { - if (index !in playlist.indices) return - - val mediaItem = playlist[index] + val mediaItem = playlist.getOrNull(index) ?: return val videoId = mediaItem.mediaId // While casting, playback starts on the receiver — never on a local ExoPlayer. @@ -1496,34 +1494,31 @@ internal class CrossfadeExoPlayerAdapter( ) } - // Use precached player if available + // Resolve player instance (promote precached player if available, else instantiate fresh) val cachedPlayerEntry = precachedPlayers.remove(videoId) - val player: ExoPlayer - val playerFilter: CrossfadeFilterAudioProcessor? - if (cachedPlayerEntry?.player != null) { - Logger.d(TAG, "Using precached player for $videoId") - player = cachedPlayerEntry.player - playerFilter = cachedPlayerEntry.filter - } else { - Logger.d(TAG, "Creating new player for $videoId") - val pwf = createExoPlayerInstance() - player = pwf.player - playerFilter = pwf.filter - player.setMediaItem(mediaItem.toMedia3MediaItem()) - player.prepare() - } - - // === CAREFUL ORDER for ForwardingPlayer integration === + val (player, playerFilter) = + if (cachedPlayerEntry?.player != null) { + Logger.d(TAG, "Promoting precached player for $videoId") + cachedPlayerEntry.player to cachedPlayerEntry.filter + } else { + Logger.d(TAG, "Instantiating fresh player for $videoId") + val pwf = createExoPlayerInstance() + pwf.player.apply { + setMediaItem(mediaItem.toMedia3MediaItem()) + prepare() + } + pwf.player to pwf.filter + } - // 1. Remove our active listener from old player + // 1. Teardown active listeners and ongoing crossfade jobs before swapping cleanupPlayerListenerInternal() stopPositionUpdates() crossfadeJob?.cancel() crossfadeJob = null setCrossfading(false) - // 2. Save old player reference - val oldPlayer = currentPlayer + // 2. Save previous player reference + val previousPlayer = currentPlayer // 3. Set new player as current currentPlayer = player @@ -1532,41 +1527,24 @@ internal class CrossfadeExoPlayerAdapter( // 4. Setup our listener on new player setupPlayerListenerInternal(player) - // 5. Swap ForwardingPlayer delegate (moves MediaSession's listeners from old to new) - forwardingPlayer.swapDelegate(player) - - // 5b. Notify MediaSession about the new media item - // The MediaItem was set before the swap (either during precache or above), - // so MediaSession's listener missed the onMediaItemTransition event. - // play() below will trigger onIsPlayingChanged which causes MediaSession - // to re-query metadata, but this explicit notify is safer and ensures - // the notification updates immediately even if play() is delayed. - forwardingPlayer.notifyMediaItemChanged() - - // 6. NOW release old player (it has no listeners anymore) - if (oldPlayer != null && oldPlayer !== player) { - try { - oldPlayer.stop() - oldPlayer.release() - } catch (e: Exception) { - Logger.w(TAG, "Error releasing old player: ${e.message}") + // 5. Swap between previous and current + + // Configure playback parameters/initial play state prior to delegate swapping. + // Swapping the delegate before starting playback causes MediaSession to observe an idle state, + // which arms the Android foreground service demotion watchdog. + player.apply { + // Apply settings + volume = internalVolume + playbackParameters = PlaybackParameters(internalPlaybackSpeed, internalPlaybackPitch) + skipSilenceEnabled = internalSkipSilence + + // Seek if needed + if (startPositionMs > 0) { + seekTo(startPositionMs) + cachedPosition = startPositionMs } } - // Audio focus is held at the adapter level (see Audio Focus section), - // not per-player, so it survives this swap (#2155). - - // Apply settings - player.volume = internalVolume - player.playbackParameters = PlaybackParameters(internalPlaybackSpeed, internalPlaybackPitch) - player.skipSilenceEnabled = internalSkipSilence - - // Seek if needed - if (startPositionMs > 0) { - player.seekTo(startPositionMs) - cachedPosition = startPositionMs - } - // Auto-play if requested if (shouldPlay) { requestAudioFocusInternal() @@ -1577,9 +1555,20 @@ internal class CrossfadeExoPlayerAdapter( transitionToState(InternalState.READY) } - forwardingPlayer.suppressPlaybackEnded = false + forwardingPlayer.swapDelegate(player) + forwardingPlayer.notifyMediaItemChanged() + + // Asynchronously release the old player instance to prevent blocking the transition + if (previousPlayer != null && previousPlayer !== player) { + runCatching { + previousPlayer.stop() + previousPlayer.release() + }.onFailure { + Logger.w(TAG, "Failed to release previous player: ${it.message}") + } + } - // Start position updates + forwardingPlayer.suppressPlaybackEnded = false startPositionUpdates() // Eagerly load audio metadata for auto crossfade calculations @@ -1592,8 +1581,7 @@ internal class CrossfadeExoPlayerAdapter( triggerPrecachingInternal() } catch (e: Exception) { if (e is CancellationException) throw e - Logger.e(TAG, "Load track error: ${e.message}", e) - forwardingPlayer.suppressPlaybackEnded = false + Logger.e(TAG, "Failed to load and play track: ${e.message}", e) transitionToState(InternalState.ERROR) } } diff --git a/media/media3/src/main/java/com/maxrave/media3/service/SimpleMediaService.kt b/media/media3/src/main/java/com/maxrave/media3/service/SimpleMediaService.kt index 7765c0f6..2da18fcb 100644 --- a/media/media3/src/main/java/com/maxrave/media3/service/SimpleMediaService.kt +++ b/media/media3/src/main/java/com/maxrave/media3/service/SimpleMediaService.kt @@ -150,18 +150,10 @@ internal class SimpleMediaService : notification: Notification, ongoing: Boolean, ) { - fun startFg() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) - } else { - startForeground(notificationId, notification) - } - } - coroutineScope.launch { - while (coroutineScope.isActive) { - startFg() - delay(30.seconds) - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(notificationId, notification) } } }, @@ -197,7 +189,11 @@ internal class SimpleMediaService : session: MediaSession, startInForegroundRequired: Boolean, ) { - super.onUpdateNotification(session, startInForegroundRequired) + val player = session.player + val isActivelyPlaying = + player.playWhenReady && + (player.playbackState == Player.STATE_READY || player.playbackState == Player.STATE_BUFFERING) + super.onUpdateNotification(session, startInForegroundRequired || isActivelyPlaying) } @UnstableApi