From b2e1bc7acda7b00982b6346f7300710b73082914 Mon Sep 17 00:00:00 2001 From: vrdons Date: Thu, 3 Sep 2026 15:46:50 +0300 Subject: [PATCH 1/2] fix(service): stop looping startForeground on notification updates * Spawning an endless 30 second loop on every notification update was leaking dozens of wakeups. That cooks CPU for no reason. Call startForeground directly inside onNotificationPosted instead of running recurring background loops. --- .../media3/service/SimpleMediaService.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) 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 From f1b153f01376b00c377dc0d2a9c2b79ba2e2c415 Mon Sep 17 00:00:00 2001 From: vrdons Date: Thu, 3 Sep 2026 15:59:42 +0300 Subject: [PATCH 2/2] refactor(player): clean up track loading flow and delegate swap in CrossfadeExoPlayerAdapter * Use playlist.getOrNull() for safe indexing instead of checking bounds manually. * Streamline player resolution (precached vs fresh instance) with a destructuring pair. * Replace raw try-catch on old player release with runCatching. --- .../exoplayer/CrossfadeExoPlayerAdapter.kt | 106 ++++++++---------- 1 file changed, 47 insertions(+), 59 deletions(-) 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) } }