From 59513e8ae344673267d3c07df77fc3e1dd110209 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:55:55 +0300 Subject: [PATCH 01/17] fix(music): keep the gapless advance instead of seeking to the start --- .../io/github/aedev/flow/service/Media3MusicService.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index 48d9ce273b..099d9ab0ae 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -452,13 +452,6 @@ class Media3MusicService : MediaLibraryService() { } applyLoudnessGain() - if ( - reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || - reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT - ) { - player.seekTo(0L) - } - mediaItem?.let { item -> val videoId = item.mediaId val title = item.mediaMetadata.title?.toString() From 8f46edb49bd4b779548d169004dffba665fbfc41 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:11 +0300 Subject: [PATCH 02/17] fix(player): release the placeholder surface after the new surface is attached --- .../flow/player/surface/SurfaceManager.kt | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/surface/SurfaceManager.kt b/app/src/main/java/io/github/aedev/flow/player/surface/SurfaceManager.kt index 1d7c087c8f..2c465c8c87 100644 --- a/app/src/main/java/io/github/aedev/flow/player/surface/SurfaceManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/surface/SurfaceManager.kt @@ -56,12 +56,6 @@ class SurfaceManager { surfaceHolder = holder Log.d(TAG, "attachVideoSurface: stored holder. Player instance is ${if (player == null) "null" else "not null"}") - // A real surface is back, drop any placeholder we were using - placeholderSurface?.let { placeholder -> - runCatching { placeholder.release() } - placeholderSurface = null - } - if (player == null) { Log.d(TAG, "Player not initialized yet; surface will be attached later") return false @@ -74,11 +68,15 @@ class SurfaceManager { val existingSurface = runCatching { surfaceHolder?.surface }.getOrNull() if (existingSurface != null && existingSurface.isValid && existingSurface == surface) { Log.d(TAG, "Surface already attached and ready — skipping redundant setVideoSurface (update fallback)") + releasePlaceholder() return@runCatching true } } Log.d(TAG, "Attempting to attach surface ${surface.hashCode()} to player (forceAttach=$forceAttach)") player.setVideoSurface(surface) + // The codec may still be drawing into the placeholder until setVideoSurface + // returns; releasing it earlier abandons its buffer queue and kills the codec. + releasePlaceholder() Log.d(TAG, "Surface attached to player via getSurface() (NewPipe approach)") surfaceReadyFlow.value = true setSurfaceReady(true) @@ -114,9 +112,10 @@ class SurfaceManager { if (player != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (context != null) { // Try to reuse the placeholder surface if valid + var stalePlaceholder: PlaceholderSurface? = null if (placeholderSurface == null || placeholderSurface?.isValid == false) { try { - runCatching { placeholderSurface?.release() } + stalePlaceholder = placeholderSurface placeholderSurface = PlaceholderSurface.newInstance(context, false) } catch (e: Exception) { Log.w(TAG, "Failed to create placeholder surface", e) @@ -129,6 +128,7 @@ class SurfaceManager { } ?: run { player.clearVideoSurface() } + stalePlaceholder?.let { runCatching { it.release() } } } else { player.clearVideoSurface() } @@ -152,6 +152,7 @@ class SurfaceManager { val surface = holder.surface if (surface != null && surface.isValid) { player?.setVideoSurface(surface) + if (player != null) releasePlaceholder() Log.d(TAG, "Reattached preserved surface ${surface.hashCode()}") setSurfaceReady(true) true @@ -167,6 +168,13 @@ class SurfaceManager { return false } + private fun releasePlaceholder() { + placeholderSurface?.let { placeholder -> + runCatching { placeholder.release() } + placeholderSurface = null + } + } + /** * Set surface readiness state. */ @@ -238,10 +246,7 @@ class SurfaceManager { Log.w(TAG, "Failed to clear surface during release", e) } - placeholderSurface?.let { surface -> - runCatching { surface.release() } - } - placeholderSurface = null + releasePlaceholder() isSurfaceReady = false surfaceReadyFlow.value = false From 04bf93dafb342bf173599395b1d8336b09c7546f Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:59:44 +0300 Subject: [PATCH 03/17] fix(strings): restore three English strings that Weblate turned into plurals --- app/src/main/res/values/strings.xml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8026da6a9..fdbf26aafd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -518,12 +518,8 @@ Watched or not Unwatched Watched - - No watched videos - - - No unwatched videos - + No watched videos here + No unwatched videos here HD 4K HDR @@ -1217,9 +1213,7 @@ Learning Fashion YouTube Music charts - - YouTube is limiting requests right now. Try again later. - + YouTube is limiting requests right now. Try again in a few minutes. See all Back to all shelves Continue Watching shelf From b4580552192ed8c622276e56dcb242256841cb18 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:00:21 +0300 Subject: [PATCH 04/17] fix(player): re-prepare once after a video decoder error --- .../flow/player/EnhancedPlayerManager.kt | 3 +- .../flow/player/error/PlayerErrorHandler.kt | 38 ++++++++- .../error/VideoDecoderRecoveryPolicy.kt | 48 +++++++++++ .../error/VideoDecoderRecoveryPolicyTest.kt | 83 +++++++++++++++++++ 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicy.kt create mode 100644 app/src/test/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicyTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt index 7d95a6a550..57f204bbef 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt @@ -928,6 +928,7 @@ class EnhancedPlayerManager private constructor() { override fun onRenderedFirstFrame() { Log.d(TAG, "First frame rendered - video renderer working") surfaceManager?.setSurfaceReady(true) + errorHandler?.onFirstFrameRendered() surfaceFirstFrameWatchdog?.cancel() surfaceFirstFrameWatchdog = null pendingSurfaceFirstFrameStartedAtMs.takeIf { it > 0L }?.let { startedAtMs -> @@ -1224,7 +1225,7 @@ class EnhancedPlayerManager private constructor() { lastLiveEdgeRecoveryMs = 0L qualityManager?.resetForNewVideo() playbackTracker?.reset() - errorHandler?.resetExpiryCounter() + errorHandler?.resetForNewVideo() mediaLoader?.releaseSabr() currentSabrInfo = null sabrPreferred = false diff --git a/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt b/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt index ddb25244e3..1a58632657 100644 --- a/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt +++ b/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt @@ -2,9 +2,11 @@ package io.github.aedev.flow.player.error import android.content.Context import android.util.Log +import androidx.media3.common.C import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlaybackException import androidx.media3.exoplayer.ExoPlayer import io.github.aedev.flow.R import io.github.aedev.flow.player.config.PlayerConfig @@ -77,6 +79,8 @@ class PlayerErrorHandler( reloadPlaybackManager = reloadPlaybackManager, ) + private val decoderRecovery = VideoDecoderRecoveryPolicy() + // ── Public entry point ──────────────────────────────────────────────────── /** @@ -211,7 +215,7 @@ class PlayerErrorHandler( PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, -> { - handleDecoderError(error) + if (handleDecoderError(error, player)) return true } // ── DRM ──────────────────────────────────────────────────────── @@ -249,8 +253,13 @@ class PlayerErrorHandler( stateFlow.value = stateFlow.value.copy(isBuffering = true, error = null) } - fun resetExpiryCounter() { + fun resetForNewVideo() { denialRecovery.resetExpiryCounter() + decoderRecovery.reset() + } + + fun onFirstFrameRendered() { + decoderRecovery.onFirstFrameRendered() } fun hasGivenUp(): Boolean = denialRecovery.hasGivenUp() @@ -368,7 +377,11 @@ class PlayerErrorHandler( reloadPlaybackManager() } - private fun handleDecoderError(error: PlaybackException) { + /** Returns true when the error was recovered in place and needs no notification. */ + private fun handleDecoderError( + error: PlaybackException, + player: ExoPlayer?, + ): Boolean { Log.e(TAG, "Decoder/renderer error: ${error.errorCode}") PlayerDiagnostics.logError(TAG, "Decoder error (${error.errorCode}): ${error.message}", error.cause) @@ -391,10 +404,21 @@ class PlayerErrorHandler( PlayerDiagnostics.logInfo(TAG, "Switched to backup audio track: ${alternativeAudio.format?.mimeType}") setRecoveryState() reloadPlaybackManager() - return + return false } } + if (player != null && + decoderRecovery.onDecoderError(error.errorCode, error.isFormatHandled()) == + VideoDecoderRecoveryPolicy.Decision.REPREPARE + ) { + Log.w(TAG, "Decoder error — re-preparing in place at ${player.currentPosition}ms") + PlayerDiagnostics.logWarning(TAG, "Decoder failed (${error.errorCode}); re-preparing in place") + setRecoveryState() + player.prepare() + return true + } + Log.e(TAG, "Decoder error — no alternatives available, stopping playback") onPlaybackShutdown() stateFlow.value = @@ -402,6 +426,12 @@ class PlayerErrorHandler( error = appContext.getString(R.string.error_playback_device, error.message.orEmpty()), isPlaying = false, ) + return false + } + + private fun PlaybackException.isFormatHandled(): Boolean { + val exo = this as? ExoPlaybackException ?: return true + return exo.type != ExoPlaybackException.TYPE_RENDERER || exo.rendererFormatSupport == C.FORMAT_HANDLED } private fun handleDrmError(error: PlaybackException) { diff --git a/app/src/main/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicy.kt b/app/src/main/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicy.kt new file mode 100644 index 0000000000..905b836d6f --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicy.kt @@ -0,0 +1,48 @@ +package io.github.aedev.flow.player.error + +import androidx.media3.common.PlaybackException + +/** + * Decides whether a decoder failure gets one in-place re-prepare. A codec can die for reasons + * unrelated to the stream, such as losing its output surface, and Media3 keeps the item and the + * position in IDLE, so prepare() rebuilds the codec without a network round trip. + * + * One attempt is allowed until a frame renders again, so a codec that fails straight away gives + * up instead of looping; [maxPerVideo] bounds codecs that render a little and then fail again. + */ +internal class VideoDecoderRecoveryPolicy( + private val maxPerVideo: Int = MAX_REPREPARES_PER_VIDEO, +) { + enum class Decision { REPREPARE, GIVE_UP } + + private var awaitingFrame = false + private var reprepares = 0 + + fun onDecoderError( + errorCode: Int, + formatHandled: Boolean, + ): Decision { + val transient = + errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED || + errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED + if (!transient || !formatHandled || awaitingFrame || reprepares >= maxPerVideo) { + return Decision.GIVE_UP + } + awaitingFrame = true + reprepares++ + return Decision.REPREPARE + } + + fun onFirstFrameRendered() { + awaitingFrame = false + } + + fun reset() { + awaitingFrame = false + reprepares = 0 + } + + companion object { + const val MAX_REPREPARES_PER_VIDEO = 3 + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicyTest.kt new file mode 100644 index 0000000000..16a32bc8da --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/error/VideoDecoderRecoveryPolicyTest.kt @@ -0,0 +1,83 @@ +package io.github.aedev.flow.player.error + +import androidx.media3.common.PlaybackException +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.player.error.VideoDecoderRecoveryPolicy.Decision +import org.junit.Test + +class VideoDecoderRecoveryPolicyTest { + private val decodingFailed = PlaybackException.ERROR_CODE_DECODING_FAILED + + @Test + fun `first decoding failure re-prepares`() { + val policy = VideoDecoderRecoveryPolicy() + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.REPREPARE) + } + + @Test + fun `decoder init failure re-prepares`() { + val policy = VideoDecoderRecoveryPolicy() + + assertThat( + policy.onDecoderError(PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, formatHandled = true), + ).isEqualTo(Decision.REPREPARE) + } + + @Test + fun `a second failure before any frame gives up`() { + val policy = VideoDecoderRecoveryPolicy() + policy.onDecoderError(decodingFailed, formatHandled = true) + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.GIVE_UP) + } + + @Test + fun `a rendered frame allows another attempt`() { + val policy = VideoDecoderRecoveryPolicy() + policy.onDecoderError(decodingFailed, formatHandled = true) + policy.onFirstFrameRendered() + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.REPREPARE) + } + + @Test + fun `attempts per video are capped even with frames in between`() { + val policy = VideoDecoderRecoveryPolicy(maxPerVideo = 2) + repeat(2) { + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.REPREPARE) + policy.onFirstFrameRendered() + } + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.GIVE_UP) + } + + @Test + fun `a new video starts a fresh budget`() { + val policy = VideoDecoderRecoveryPolicy(maxPerVideo = 1) + policy.onDecoderError(decodingFailed, formatHandled = true) + policy.reset() + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = true)).isEqualTo(Decision.REPREPARE) + } + + @Test + fun `a format the decoder cannot handle gives up`() { + val policy = VideoDecoderRecoveryPolicy() + + assertThat(policy.onDecoderError(decodingFailed, formatHandled = false)).isEqualTo(Decision.GIVE_UP) + } + + @Test + fun `decoder query and audio track failures give up`() { + val policy = VideoDecoderRecoveryPolicy() + + listOf( + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED, + PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, + ).forEach { code -> + assertThat(policy.onDecoderError(code, formatHandled = true)).isEqualTo(Decision.GIVE_UP) + } + } +} From 955da41bd9aaa6a6ceb72206b409e2e6ce614432 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:01:30 +0300 Subject: [PATCH 05/17] fix(player): show Retry when a decoder error ends playback --- .../aedev/flow/player/EnhancedPlayerManager.kt | 4 ++++ .../aedev/flow/player/error/PlayerErrorHandler.kt | 2 ++ .../screens/player/PlaybackRecoveryController.kt | 15 +++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ 4 files changed, 23 insertions(+) diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt index 57f204bbef..7f573e6bdb 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt @@ -490,6 +490,9 @@ class EnhancedPlayerManager private constructor() { private val _playbackAbandonedEvent = MutableSharedFlow(extraBufferCapacity = 1) val playbackAbandonedEvent: SharedFlow = _playbackAbandonedEvent.asSharedFlow() + private val _decoderFailureEvent = MutableSharedFlow(extraBufferCapacity = 1) + val decoderFailureEvent: SharedFlow = _decoderFailureEvent.asSharedFlow() + private val _queueAutoAdvanceEvent = MutableSharedFlow(extraBufferCapacity = 1) val queueAutoAdvanceEvent: SharedFlow = _queueAutoAdvanceEvent.asSharedFlow() @@ -622,6 +625,7 @@ class EnhancedPlayerManager private constructor() { isPlayingDeviceFile = { currentLocalFilePath != null }, onStreamExpired = { onStreamsDenied() }, onPlaybackAbandoned = { if (!skipAbandonedVideo()) scope.launch { _playbackAbandonedEvent.emit(Unit) } }, + onDecoderGaveUp = { scope.launch { _decoderFailureEvent.emit(Unit) } }, onGatedCodecFallback = { position -> qualityManager?.fallbackToAlternateCodec(position) ?: false }, getFailedStreamUrls = { qualityManager?.let { qm -> diff --git a/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt b/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt index 1a58632657..aebbefcf88 100644 --- a/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt +++ b/app/src/main/java/io/github/aedev/flow/player/error/PlayerErrorHandler.kt @@ -45,6 +45,7 @@ class PlayerErrorHandler( private val onPlaybackShutdown: () -> Unit, private val onStreamExpired: () -> Unit, private val onPlaybackAbandoned: () -> Unit, + private val onDecoderGaveUp: () -> Unit, private val onGatedCodecFallback: (Long) -> Boolean, private val getFailedStreamUrls: () -> Set, private val markStreamFailed: (String) -> Unit, @@ -426,6 +427,7 @@ class PlayerErrorHandler( error = appContext.getString(R.string.error_playback_device, error.message.orEmpty()), isPlaying = false, ) + onDecoderGaveUp() return false } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackRecoveryController.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackRecoveryController.kt index 641bf86975..f986126e2e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackRecoveryController.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/PlaybackRecoveryController.kt @@ -33,6 +33,9 @@ internal class PlaybackRecoveryController( ?.id ?.let { videoId -> abandonReportedPlayback(videoId) } }.launchIn(scope) + playerManager.decoderFailureEvent + .onEach { reportDecoderFailure() } + .launchIn(scope) } fun onPlaybackRequested() = playerManager.resetStreamRecovery() @@ -53,6 +56,18 @@ internal class PlaybackRecoveryController( } } + /** A device fault, not a broken video, so the video is not marked unplayable. */ + private fun reportDecoderFailure() { + cancelLoad() + uiState.update { + it.copy( + isLoading = false, + error = context.getString(R.string.error_video_decoder_failed), + errorHint = context.getString(R.string.error_video_decoder_failed_hint), + ) + } + } + private companion object { const val TAG = "PlaybackRecoveryController" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdbf26aafd..a3fc1132db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3454,6 +3454,8 @@ Failed to load media: %1$s Failed to load media: Invalid streams Try again later or check your network connection. + The video decoder stopped working + Tap Retry to load the video again. Waiting for WiFi Saved to %1$s. The chosen folder can\'t be used. Download failed. Please try again. From 18eb37b05785a0a966d928bead75115e2b4d1a72 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:04:52 +0300 Subject: [PATCH 06/17] fix(dearrow): show a video frame when nothing is submitted, from one cached lookup --- .../java/io/github/aedev/flow/MainActivity.kt | 6 +- .../aedev/flow/data/model/DeArrowContent.kt | 19 +- .../data/repository/DeArrowBrandingPolicy.kt | 82 +++++++ .../flow/data/repository/DeArrowRepository.kt | 220 +++++++----------- .../aedev/flow/ui/components/DeArrowState.kt | 46 +++- .../components/shared/card/VideoCardState.kt | 6 +- .../player/content/PlayerVideoMetadata.kt | 3 +- .../repository/DeArrowBrandingPolicyTest.kt | 103 ++++++++ .../data/repository/DeArrowRepositoryTest.kt | 125 ++++++++++ 9 files changed, 454 insertions(+), 156 deletions(-) create mode 100644 app/src/main/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicy.kt create mode 100644 app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt create mode 100644 app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/MainActivity.kt b/app/src/main/java/io/github/aedev/flow/MainActivity.kt index a69580032f..41bac3b6ca 100644 --- a/app/src/main/java/io/github/aedev/flow/MainActivity.kt +++ b/app/src/main/java/io/github/aedev/flow/MainActivity.kt @@ -35,6 +35,7 @@ import io.github.aedev.flow.data.local.LocalDataManager import io.github.aedev.flow.data.playlist.PlaylistImport import io.github.aedev.flow.data.playlist.PlaylistTransfer import io.github.aedev.flow.data.recommendation.FlowNeuroEngine +import io.github.aedev.flow.data.repository.DeArrowRepository import io.github.aedev.flow.data.repository.OriginalTitleRepository import io.github.aedev.flow.discord.DiscordPresenceRuntime import io.github.aedev.flow.notification.NotificationHelper @@ -88,6 +89,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var originalTitles: OriginalTitleRepository + @Inject + lateinit var deArrow: DeArrowRepository + @Inject lateinit var lifecyclePlaybackPreferences: LifecyclePlaybackPreferences @@ -263,7 +267,7 @@ class MainActivity : ComponentActivity() { // Card preferences and watch progress are collected once here. Cards used to // collect them individually, so a feed of ten opened ten Room observers and // fifty DataStore collectors. - ProvideVideoCardState(originalTitles) { + ProvideVideoCardState(originalTitles, deArrow) { Box( modifier = Modifier diff --git a/app/src/main/java/io/github/aedev/flow/data/model/DeArrowContent.kt b/app/src/main/java/io/github/aedev/flow/data/model/DeArrowContent.kt index fc5bf58bd6..7a423d898f 100644 --- a/app/src/main/java/io/github/aedev/flow/data/model/DeArrowContent.kt +++ b/app/src/main/java/io/github/aedev/flow/data/model/DeArrowContent.kt @@ -1,25 +1,30 @@ package io.github.aedev.flow.data.model +import kotlinx.serialization.Serializable + +@Serializable data class DeArrowTitle( val title: String = "", val votes: Int = 0, val locked: Boolean = false, - val original: Boolean = false + val original: Boolean = false, ) +@Serializable data class DeArrowThumbnail( - val timestamp: Float? = null, - val thumbnail: String? = null, + val timestamp: Double? = null, val votes: Int = 0, val locked: Boolean = false, - val original: Boolean = false + val original: Boolean = false, ) +/** The `/api/branding` answer. Submissions arrive best first, as the server ranks them. */ +@Serializable data class DeArrowContent( val titles: List = emptyList(), val thumbnails: List = emptyList(), - val randomTime: Float? = null, - val videoDuration: Float? = null + val randomTime: Double? = null, + val videoDuration: Double? = null, ) /** @@ -27,5 +32,5 @@ data class DeArrowContent( */ data class DeArrowResult( val title: String? = null, - val thumbnailUrl: String? = null + val thumbnailUrl: String? = null, ) diff --git a/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicy.kt b/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicy.kt new file mode 100644 index 0000000000..7b0d7c2ecb --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicy.kt @@ -0,0 +1,82 @@ +package io.github.aedev.flow.data.repository + +import io.github.aedev.flow.data.model.DeArrowContent +import io.github.aedev.flow.data.model.DeArrowResult +import okhttp3.HttpUrl.Companion.toHttpUrl + +/** + * Turns a branding answer into what a card shows, the way the DeArrow extension does: the server's + * first usable submission wins, a winning "original" keeps YouTube's version, and a video with no + * thumbnail submission shows the frame at the server's random time for it, so every DeArrow user + * sees the same one. + */ +internal object DeArrowBrandingPolicy { + const val THUMBNAIL_URL = "https://dearrow-thumb.ajay.app/api/v1/getThumbnail" + + fun resolve( + content: DeArrowContent, + videoId: String, + fallbackDurationSeconds: Int, + useRandomFrame: Boolean, + isLiveOrUpcoming: Boolean, + ): DeArrowResult? { + val title = + content.titles + .firstOrNull { it.locked || it.votes >= 0 } + ?.takeUnless { it.original } + ?.title + ?.let(::stripCaseMarkers) + ?.takeIf { it.isNotBlank() } + + val thumbnail = content.thumbnails.firstOrNull { it.locked || it.votes >= 0 } + val frameTime = + when { + thumbnail != null -> thumbnail.takeUnless { it.original }?.timestamp + useRandomFrame && !isLiveOrUpcoming -> randomFrameTime(content, fallbackDurationSeconds) + else -> null + } + val thumbnailUrl = frameTime?.let { frameUrl(videoId, it) } + + return if (title == null && thumbnailUrl == null) null else DeArrowResult(title, thumbnailUrl) + } + + private fun randomFrameTime( + content: DeArrowContent, + fallbackDurationSeconds: Int, + ): Double? { + val randomTime = content.randomTime ?: return null + val duration = + content.videoDuration?.takeIf { it > 0 } + ?: fallbackDurationSeconds.takeIf { it > 0 }?.toDouble() + ?: return null + return randomTime * duration + } + + fun frameUrl( + videoId: String, + timeSeconds: Double, + ): String = + THUMBNAIL_URL + .toHttpUrl() + .newBuilder() + .addQueryParameter("videoID", videoId) + .addQueryParameter("time", timeSeconds.toString()) + .build() + .toString() +} + +/** + * DeArrow submitters prefix a word with `>` to keep its casing when titles are auto-formatted; the + * marker is formatting metadata, never part of the title. + */ +internal fun stripCaseMarkers(title: String): String = + buildString(title.length) { + var atWordStart = true + title.forEachIndexed { i, c -> + val isMarker = + c == '>' && atWordStart && + title.getOrNull(i + 1)?.isWhitespace() == false + if (!isMarker) append(c) + atWordStart = c.isWhitespace() + } + } diff --git a/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowRepository.kt b/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowRepository.kt index 2bd6c73d7a..ce057c1680 100644 --- a/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowRepository.kt +++ b/app/src/main/java/io/github/aedev/flow/data/repository/DeArrowRepository.kt @@ -1,160 +1,114 @@ package io.github.aedev.flow.data.repository -import android.util.LruCache -import com.google.gson.Gson import io.github.aedev.flow.data.model.DeArrowContent -import io.github.aedev.flow.data.model.DeArrowResult -import io.github.aedev.flow.network.AppProxyManager -import kotlinx.coroutines.Dispatchers +import io.github.aedev.flow.di.NetworkIoDispatcher +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton /** - * Fetches DeArrow branding data from the SponsorBlock API. + * DeArrow branding (community titles and thumbnails, https://dearrow.ajay.app/) per video. * - * DeArrow replaces clickbait titles and thumbnails with community-submitted, - * more accurate alternatives. See https://dearrow.ajay.app/ - * - * Results are cached in-memory (LRU, 200 entries) to prevent redundant network calls. + * A video nobody has submitted for answers 404 with a usable body (the random frame time), so 404 + * is cached like any answer. One request per video per [TTL_MS]: concurrent asks share a request + * and [MAX_PARALLEL] keeps a fast scroll from filling the network pool. */ -object DeArrowRepository { - private val client: OkHttpClient - get() = AppProxyManager.applyTo(OkHttpClient.Builder()).build() - private val gson = Gson() - - private const val BRANDING_BASE_URL = "https://sponsor.ajay.app/api/branding" - private const val THUMBNAIL_BASE_URL = "https://dearrow-thumb.ajay.app/api/v1/getThumbnail" - - /** - * LRU cache mapping videoId -> DeArrowResult (null = fetched but no useful data). - * Capacity: 200 entries ≈ a few home page loads. - */ - private val cache = LruCache>(200) - - /** Wrapper to distinguish "cache miss" from "cached null" */ - private class Optional( - val value: T?, +@Singleton +class DeArrowRepository internal constructor( + private val client: OkHttpClient, + private val dispatcher: CoroutineDispatcher, + private val clockMs: () -> Long, +) { + @Inject + constructor( + client: OkHttpClient, + @NetworkIoDispatcher dispatcher: CoroutineDispatcher, + ) : this(client, dispatcher, { System.nanoTime() / 1_000_000 }) + + private class Entry( + val content: DeArrowContent?, + val atMs: Long, ) - /** - * Returns the DeArrow result for [videoId], or null if: - * - DeArrow has no data for this video - * - The network request failed - * - * Results are cached so the same video is only fetched once per session. - */ - suspend fun getDeArrowResult(videoId: String): DeArrowResult? = - withContext(Dispatchers.IO) { - // Cache hit - cache.get(videoId)?.let { return@withContext it.value } - - try { - val request = - Request - .Builder() - .url("$BRANDING_BASE_URL?videoID=$videoId") - .header("User-Agent", "FlowYouTube/1.0") - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext null - } - val body = response.body?.string() - if (body.isNullOrBlank()) { - cache.put(videoId, Optional(null)) - return@withContext null - } - val parsed = parseResponse(body, videoId) - cache.put(videoId, Optional(parsed)) - return@withContext parsed - } - } catch (e: Exception) { - return@withContext null - } + private val answers = + object : LinkedHashMap(CAPACITY, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > CAPACITY } + private val inFlight = ConcurrentHashMap>() + private val permits = Semaphore(MAX_PARALLEL) - fun getCachedDeArrowResult(videoId: String): DeArrowResult? = cache.get(videoId)?.value + /** What a request already answered, for a card's first frame. */ + fun cached(videoId: String): DeArrowContent? = fresh(videoId)?.content - private fun parseResponse( - json: String, - videoId: String, - ): DeArrowResult? = + suspend fun branding(videoId: String): DeArrowContent? { + fresh(videoId)?.let { return it.content } + val mine = CompletableDeferred() + inFlight.putIfAbsent(videoId, mine)?.let { return it.await() } try { - val content = gson.fromJson(json, DeArrowContent::class.java) - extractResult(content, videoId) - } catch (e: Exception) { - null + val content = permits.withPermit { fetch(videoId) } + mine.complete(content) + return content + } catch (e: CancellationException) { + mine.complete(null) + throw e + } finally { + inFlight.remove(videoId, mine) } + } - /** - * Picks the best title and thumbnail from [content]: - * - Prefers locked entries (manually verified) over voted ones - * - Ignores entries with negative votes (downvoted) - * - Ignores "original" entries (these just mean "keep as-is") - */ - private fun extractResult( - content: DeArrowContent, - videoId: String, - ): DeArrowResult? { - val title = - content.titles - .filter { !it.original && (it.votes >= 0 || it.locked) } - .maxByOrNull { if (it.locked) Int.MAX_VALUE else it.votes } - ?.title - ?.let(::stripCaseMarkers) - - val bestThumb = - content.thumbnails - .filter { !it.original && (it.votes >= 0 || it.locked) } - .maxByOrNull { if (it.locked) Int.MAX_VALUE else it.votes } - - val thumbnailUrl = - when { - bestThumb?.thumbnail != null -> { - bestThumb.thumbnail - } - - bestThumb?.timestamp != null -> { - "$THUMBNAIL_BASE_URL?videoID=$videoId&time=${bestThumb.timestamp}" - } - - else -> { - null + private suspend fun fetch(videoId: String): DeArrowContent? = + withContext(dispatcher) { + val url = + BRANDING_URL + .toHttpUrl() + .newBuilder() + .addQueryParameter("videoID", videoId) + .build() + try { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + if (!response.isSuccessful && response.code != 404) return@use null + parse(response.body.string()).also { store(videoId, it) } } + } catch (e: IOException) { + null } - - return if (title == null && thumbnailUrl == null) { - null - } else { - DeArrowResult(title = title, thumbnailUrl = thumbnailUrl) } - } - /** Removes a cached entry, forcing a fresh fetch next time. */ - fun invalidate(videoId: String) { - cache.remove(videoId) + @Synchronized + private fun fresh(videoId: String): Entry? { + val entry = answers[videoId] ?: return null + if (clockMs() - entry.atMs < TTL_MS) return entry + answers.remove(videoId) + return null } - /** Clears the entire in-memory cache. */ - fun clearCache() { - cache.evictAll() + @Synchronized + private fun store( + videoId: String, + content: DeArrowContent?, + ) { + answers[videoId] = Entry(content, clockMs()) } -} -/** - * DeArrow submitters prefix a word with `>` to keep its casing when titles are auto-formatted; the - * marker is formatting metadata, never part of the title. - */ -internal fun stripCaseMarkers(title: String): String = - buildString(title.length) { - var atWordStart = true - title.forEachIndexed { i, c -> - val isMarker = - c == '>' && atWordStart && - title.getOrNull(i + 1)?.isWhitespace() == false - if (!isMarker) append(c) - atWordStart = c.isWhitespace() - } + internal companion object { + const val BRANDING_URL = "https://sponsor.ajay.app/api/branding" + const val CAPACITY = 1_000 + const val MAX_PARALLEL = 4 + const val TTL_MS = 60 * 60 * 1000L + + private val json = Json { ignoreUnknownKeys = true } + + fun parse(body: String): DeArrowContent? = runCatching { json.decodeFromString(body) }.getOrNull() } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/DeArrowState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/DeArrowState.kt index 44b68a1817..0da7a6ebde 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/DeArrowState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/DeArrowState.kt @@ -3,21 +3,43 @@ package io.github.aedev.flow.ui.components import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.runtime.produceState +import androidx.compose.runtime.staticCompositionLocalOf +import io.github.aedev.flow.data.localmedia.LocalMediaIds +import io.github.aedev.flow.data.model.DeArrowContent import io.github.aedev.flow.data.model.DeArrowResult +import io.github.aedev.flow.data.model.Video +import io.github.aedev.flow.data.repository.DeArrowBrandingPolicy import io.github.aedev.flow.data.repository.DeArrowRepository +/** The activity's DeArrow lookup, installed by `ProvideVideoCardState`; null in previews and tests. */ +val LocalDeArrow = staticCompositionLocalOf { null } + +/** + * What DeArrow replaces on [video], or null to keep YouTube's: switched off, a local file, no lookup + * installed, or nothing to replace. An answer already fetched shows on the first frame. + */ @Composable -internal fun rememberDeArrowResult(videoId: String, enabled: Boolean): DeArrowResult? = - key(videoId, enabled) { - produceState( - initialValue = if (enabled) { - DeArrowRepository.getCachedDeArrowResult(videoId) - } else { - null - }, - key1 = videoId, - key2 = enabled, - ) { - value = if (enabled) DeArrowRepository.getDeArrowResult(videoId) else null +internal fun rememberDeArrowResult( + video: Video, + enabled: Boolean, + useRandomFrame: Boolean, +): DeArrowResult? { + val repository = LocalDeArrow.current + val active = enabled && repository != null && !LocalMediaIds.isLocal(video.id) + val resolve = { content: DeArrowContent? -> + content?.let { + DeArrowBrandingPolicy.resolve( + content = it, + videoId = video.id, + fallbackDurationSeconds = video.duration, + useRandomFrame = useRandomFrame, + isLiveOrUpcoming = video.isLive || video.isUpcoming, + ) + } + } + return key(video.id, active, useRandomFrame) { + produceState(initialValue = if (active) resolve(repository?.cached(video.id)) else null, video.id, active, useRandomFrame) { + if (active) value = resolve(repository?.branding(video.id)) }.value } +} diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt index 29ccd57527..c115f64bb3 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt @@ -20,7 +20,9 @@ import io.github.aedev.flow.data.local.ViewHistory import io.github.aedev.flow.data.local.dao.WatchProgress import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.data.model.VideoCollaborator +import io.github.aedev.flow.data.repository.DeArrowRepository import io.github.aedev.flow.data.repository.OriginalTitleRepository +import io.github.aedev.flow.ui.components.LocalDeArrow import io.github.aedev.flow.ui.components.layout.navigation.MediaNavigator import io.github.aedev.flow.ui.components.rememberDeArrowResult import io.github.aedev.flow.ui.components.shared.LocalThumbnailQuality @@ -118,6 +120,7 @@ val LocalVideoCardActions = staticCompositionLocalOf { VideoCardActions.None } @Composable fun ProvideVideoCardState( originalTitles: OriginalTitleRepository?, + deArrow: DeArrowRepository?, quickActions: QuickActionsViewModel = sharedQuickActionsViewModel(), content: @Composable () -> Unit, ) { @@ -177,6 +180,7 @@ fun ProvideVideoCardState( LocalVideoCardActions provides actions, LocalThumbnailQuality provides thumbnailQuality, LocalOriginalTitles provides originalTitles, + LocalDeArrow provides deArrow, content = content, ) } @@ -233,7 +237,7 @@ internal class VideoCardState( @Composable internal fun rememberVideoCardState(video: Video): VideoCardState { val preferences = LocalVideoCardPreferences.current - val deArrow = rememberDeArrowResult(video.id, preferences.deArrowEnabled) + val deArrow = rememberDeArrowResult(video, preferences.deArrowEnabled, useRandomFrame = true) val originalTitle = rememberOriginalTitle(video.id, preferences.originalTitlesEnabled && deArrow?.title == null) val collaborators = rememberCollaboratorItems(video) val channelName = rememberCollaboratorChannelDisplayName(video.channelName, collaborators) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerVideoMetadata.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerVideoMetadata.kt index 36fab36883..fda01d083d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerVideoMetadata.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/content/PlayerVideoMetadata.kt @@ -7,7 +7,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import io.github.aedev.flow.R -import io.github.aedev.flow.data.localmedia.LocalMediaIds import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.data.model.VideoCollaborator import io.github.aedev.flow.data.model.needsCollaboratorResolution @@ -41,7 +40,7 @@ internal fun rememberPlayerVideoMetadata( deArrowEnabled: Boolean, context: Context, ): PlayerVideoMetadata { - val deArrowResult = rememberDeArrowResult(video.id, deArrowEnabled && !LocalMediaIds.isLocal(video.id)) + val deArrowResult = rememberDeArrowResult(video, deArrowEnabled, useRandomFrame = false) val cachedOriginal = rememberKnownOriginalTitle(video.id, LocalVideoCardPreferences.current.originalTitlesEnabled) val resolvedVideoTitle = deArrowResult?.title ?: cachedOriginal ?: video.title val needsCollaboratorResolution = video.needsCollaboratorResolution() diff --git a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt new file mode 100644 index 0000000000..d4cd40b25c --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt @@ -0,0 +1,103 @@ +package io.github.aedev.flow.data.repository + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.model.DeArrowContent +import io.github.aedev.flow.data.model.DeArrowThumbnail +import io.github.aedev.flow.data.model.DeArrowTitle +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Test + +class DeArrowBrandingPolicyTest { + private fun resolve( + content: DeArrowContent, + duration: Int = 600, + useRandomFrame: Boolean = true, + live: Boolean = false, + ) = DeArrowBrandingPolicy.resolve(content, "abc123def45", duration, useRandomFrame, live) + + private fun frameTime(url: String?): Double? = url?.toHttpUrl()?.queryParameter("time")?.toDouble() + + @Test + fun `a video nobody submitted for shows the frame at the random time`() { + val result = resolve(DeArrowContent(randomTime = 0.25, videoDuration = 400.0)) + + assertThat(result?.title).isNull() + assertThat(frameTime(result?.thumbnailUrl)).isEqualTo(100.0) + assertThat(result?.thumbnailUrl?.toHttpUrl()?.queryParameter("videoID")).isEqualTo("abc123def45") + } + + @Test + fun `a missing server duration falls back to the card's`() { + val result = resolve(DeArrowContent(randomTime = 0.5, videoDuration = null), duration = 300) + + assertThat(frameTime(result?.thumbnailUrl)).isEqualTo(150.0) + } + + @Test + fun `no duration anywhere means no frame`() { + assertThat(resolve(DeArrowContent(randomTime = 0.5), duration = 0)).isNull() + } + + @Test + fun `the random frame can be switched off`() { + assertThat(resolve(DeArrowContent(randomTime = 0.5, videoDuration = 100.0), useRandomFrame = false)).isNull() + } + + @Test + fun `live and upcoming videos get no random frame`() { + assertThat(resolve(DeArrowContent(randomTime = 0.5, videoDuration = 100.0), live = true)).isNull() + } + + @Test + fun `the server's first submission wins`() { + val content = + DeArrowContent( + titles = listOf(DeArrowTitle("Locked title", votes = 1, locked = true), DeArrowTitle("Popular title", votes = 9)), + thumbnails = listOf(DeArrowThumbnail(timestamp = 3.5, votes = 2, locked = true), DeArrowThumbnail(timestamp = 80.0, votes = 7)), + randomTime = 0.5, + videoDuration = 100.0, + ) + + val result = resolve(content) + + assertThat(result?.title).isEqualTo("Locked title") + assertThat(frameTime(result?.thumbnailUrl)).isEqualTo(3.5) + } + + @Test + fun `a winning original keeps youtube's title and thumbnail`() { + val content = + DeArrowContent( + titles = listOf(DeArrowTitle("Original", votes = 5, original = true), DeArrowTitle("Other", votes = 1)), + thumbnails = listOf(DeArrowThumbnail(votes = 5, original = true), DeArrowThumbnail(timestamp = 10.0, votes = 1)), + randomTime = 0.5, + videoDuration = 100.0, + ) + + assertThat(resolve(content)).isNull() + } + + @Test + fun `a downvoted unlocked submission is skipped`() { + val content = + DeArrowContent( + titles = listOf(DeArrowTitle("Bad", votes = -2), DeArrowTitle("Good", votes = 0)), + thumbnails = listOf(DeArrowThumbnail(timestamp = 5.0, votes = -1)), + randomTime = 0.1, + videoDuration = 100.0, + ) + + val result = resolve(content) + + assertThat(result?.title).isEqualTo("Good") + assertThat(frameTime(result?.thumbnailUrl)).isWithin(1e-9).of(10.0) + } + + @Test + fun `case markers are stripped from a submitted title`() { + val result = resolve(DeArrowContent(titles = listOf(DeArrowTitle(">iPhone review", votes = 1))), useRandomFrame = false) + + assertThat(result?.title).isEqualTo("iPhone review") + assertThat(result?.thumbnailUrl).isNull() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt new file mode 100644 index 0000000000..e8aeea90c7 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt @@ -0,0 +1,125 @@ +package io.github.aedev.flow.data.repository + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class DeArrowRepositoryTest { + private val requests = AtomicInteger() + private var nowMs = 1_000L + private var askedVideoId: String? = null + + private fun repository( + gate: CountDownLatch? = null, + answer: () -> Pair, + ): DeArrowRepository { + val client = + OkHttpClient + .Builder() + .addInterceptor { chain -> + requests.incrementAndGet() + askedVideoId = chain.request().url.queryParameter("videoID") + gate?.await(5, TimeUnit.SECONDS) + val (code, body) = answer() + if (code < 0) throw IOException("offline") + Response + .Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + }.build() + return DeArrowRepository(client, Dispatchers.IO) { nowMs } + } + + @Test + fun `a 404 for no submissions is an answer with the random time`() = + runBlocking { + val deArrow = repository { 404 to """{"titles":[],"thumbnails":[],"casualVotes":[],"randomTime":0.4,"videoDuration":250.5}""" } + + val content = deArrow.branding("abc123def45") + + assertThat(content?.randomTime).isEqualTo(0.4) + assertThat(content?.videoDuration).isEqualTo(250.5) + assertThat(askedVideoId).isEqualTo("abc123def45") + } + + @Test + fun `a 404 is cached like any answer`() = + runBlocking { + val deArrow = repository { 404 to """{"titles":[],"thumbnails":[],"randomTime":0.4,"videoDuration":null}""" } + + repeat(3) { deArrow.branding("abc123def45") } + + assertThat(requests.get()).isEqualTo(1) + assertThat(deArrow.cached("abc123def45")?.randomTime).isEqualTo(0.4) + } + + @Test + fun `cards asking at the same time share one request`() = + runBlocking { + val gate = CountDownLatch(1) + val deArrow = repository(gate) { 200 to """{"titles":[{"title":"t","votes":1,"locked":false,"original":false}],"thumbnails":[]}""" } + + val asks = (1..5).map { async(Dispatchers.IO) { deArrow.branding("abc123def45") } } + Thread.sleep(200) + gate.countDown() + + assertThat(asks.awaitAll().map { it?.titles?.single()?.title }).containsExactly("t", "t", "t", "t", "t") + assertThat(requests.get()).isEqualTo(1) + } + + @Test + fun `a network failure is asked again next time`() = + runBlocking { + val deArrow = repository { -1 to "" } + + assertThat(deArrow.branding("abc123def45")).isNull() + assertThat(deArrow.branding("abc123def45")).isNull() + assertThat(requests.get()).isEqualTo(2) + } + + @Test + fun `a server error is not cached`() = + runBlocking { + val deArrow = repository { 503 to "" } + + deArrow.branding("abc123def45") + deArrow.branding("abc123def45") + + assertThat(requests.get()).isEqualTo(2) + } + + @Test + fun `an answer expires after the ttl`() = + runBlocking { + val deArrow = repository { 200 to """{"titles":[],"thumbnails":[]}""" } + + deArrow.branding("abc123def45") + nowMs += DeArrowRepository.TTL_MS + 1 + assertThat(deArrow.cached("abc123def45")).isNull() + deArrow.branding("abc123def45") + + assertThat(requests.get()).isEqualTo(2) + } + + @Test + fun `unknown fields are ignored and broken bodies parse to nothing`() { + assertThat(DeArrowRepository.parse("""{"titles":[],"thumbnails":[],"extra":{"a":1}}""")).isNotNull() + assertThat(DeArrowRepository.parse("")).isNull() + } +} From 9cba6a85fa2eff2077c5b388a66809d14f1b9dae Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:05:32 +0300 Subject: [PATCH 07/17] feat(dearrow): choose whether to use a video frame when nothing is submitted --- .../aedev/flow/data/local/PlayerPreferences.kt | 12 ++++++++++++ .../flow/ui/components/shared/card/VideoCardState.kt | 5 ++++- .../ui/screens/settings/index/IntegrationsIndex.kt | 10 +++++++++- .../settings/integrations/IntegrationsScreen.kt | 1 + .../settings/integrations/IntegrationsViewModel.kt | 3 +++ app/src/main/res/values/strings.xml | 2 ++ 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 6ac31f423f..54d7bf2d8d 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -256,6 +256,7 @@ class PlayerPreferences( // DeArrow val DEARROW_ENABLED = booleanPreferencesKey("dearrow_enabled") val DEARROW_BADGE_ENABLED = booleanPreferencesKey("dearrow_badge_enabled") + val DEARROW_RANDOM_FRAME = booleanPreferencesKey("dearrow_random_frame") // Notification preferences val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") @@ -1732,6 +1733,17 @@ class PlayerPreferences( } } + /** DeArrow's own default: a video with no thumbnail submission shows a frame from the video. */ + val deArrowRandomFrame: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.DEARROW_RANDOM_FRAME] ?: true } + + suspend fun setDeArrowRandomFrame(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.DEARROW_RANDOM_FRAME] = enabled + } + } + // ========== NOTIFICATION PREFERENCES ========== val notificationsEnabled: Flow = diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt index c115f64bb3..74ef24c410 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt @@ -49,6 +49,7 @@ data class VideoCardPreferences( val upcomingReminderIds: Set = emptySet(), val showWatchProgress: Boolean = true, val originalTitlesEnabled: Boolean = false, + val deArrowRandomFrame: Boolean = true, ) /** @@ -149,6 +150,8 @@ fun ProvideVideoCardState( cardPreferences.copy(showWatchProgress = showWatchProgress) }.combine(playerPreferences.originalTitles) { cardPreferences, originalTitles -> cardPreferences.copy(originalTitlesEnabled = originalTitles) + }.combine(playerPreferences.deArrowRandomFrame) { cardPreferences, randomFrame -> + cardPreferences.copy(deArrowRandomFrame = randomFrame) }.distinctUntilChanged() } val preferences by preferencesFlow.collectAsStateWithLifecycle(VideoCardPreferences()) @@ -237,7 +240,7 @@ internal class VideoCardState( @Composable internal fun rememberVideoCardState(video: Video): VideoCardState { val preferences = LocalVideoCardPreferences.current - val deArrow = rememberDeArrowResult(video, preferences.deArrowEnabled, useRandomFrame = true) + val deArrow = rememberDeArrowResult(video, preferences.deArrowEnabled, preferences.deArrowRandomFrame) val originalTitle = rememberOriginalTitle(video.id, preferences.originalTitlesEnabled && deArrow?.title == null) val collaborators = rememberCollaboratorItems(video) val channelName = rememberCollaboratorChannelDisplayName(video.channelName, collaborators) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt index b1fae6225a..20c889635f 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt @@ -49,6 +49,14 @@ internal object IntegrationsIndex { keywords = R.string.settings_keywords_dearrow, destination = page, ) + val deArrowRandomFrame = + SettingEntry( + key = "integrations.dearrow.random_frame", + title = R.string.dearrow_random_frame, + summary = R.string.dearrow_random_frame_subtitle, + section = R.string.player_settings_dearrow, + destination = page, + ) val deArrowBadge = SettingEntry( key = "integrations.dearrow.badge", @@ -84,5 +92,5 @@ internal object IntegrationsIndex { destination = page, ) - val all = listOf(sponsorBlock, segments, contribute, userId, deArrow, deArrowBadge, dislikes, discord, discordAccount) + val all = listOf(sponsorBlock, segments, contribute, userId, deArrow, deArrowRandomFrame, deArrowBadge, dislikes, discord, discordAccount) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsScreen.kt index fa9d8639a6..575509ac02 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsScreen.kt @@ -75,6 +75,7 @@ internal fun IntegrationsScreen( } group(key = "integrations.dearrow.group", header = R.string.player_settings_dearrow) { switch(IntegrationsIndex.deArrow, viewModel.deArrow, viewModel::setDeArrow, icon = Icons.Outlined.AutoFixHigh) + switch(IntegrationsIndex.deArrowRandomFrame, viewModel.deArrowRandomFrame, viewModel::setDeArrowRandomFrame, enabled = deArrow) switch(IntegrationsIndex.deArrowBadge, viewModel.deArrowBadge, viewModel::setDeArrowBadge, enabled = deArrow) } group(key = "integrations.dislikes.group", header = R.string.player_settings_rytd_title) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsViewModel.kt index 5c85fe1ff5..ebbbbdc90c 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/integrations/IntegrationsViewModel.kt @@ -33,6 +33,7 @@ class IntegrationsViewModel val userId = preferences.sbUserId.asState(null) val deArrow = preferences.deArrowEnabled.asState(false) val deArrowBadge = preferences.deArrowBadgeEnabled.asState(false) + val deArrowRandomFrame = preferences.deArrowRandomFrame.asState(true) val dislikes = preferences.rytdEnabled.asState(true) val discordState = discord.settingsState @@ -78,6 +79,8 @@ class IntegrationsViewModel fun setDeArrowBadge(value: Boolean) = write { preferences.setDeArrowBadgeEnabled(value) } + fun setDeArrowRandomFrame(value: Boolean) = write { preferences.setDeArrowRandomFrame(value) } + fun setDislikes(value: Boolean) = write { preferences.setRytdEnabled(value) } fun setDiscordEnabled(value: Boolean) = write { discord.setEnabled(value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a3fc1132db..7474c7a646 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1319,6 +1319,8 @@ Swap clickbait titles and thumbnails for community ones DeArrow Show DeArrow Badge + Use a video frame + When nothing is submitted, show a frame from the video Display a badge on thumbnails corrected by DeArrow Global Video Loop Loop all videos by default From de4083fc4598b5f6a29c107caac27f1493cea9c7 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:06:11 +0300 Subject: [PATCH 08/17] feat(dearrow): DeArrow titles on Shorts cards --- .../flow/ui/components/shared/MediaShortCard.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt index 27c48d28f6..555734432a 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt @@ -33,7 +33,9 @@ import io.github.aedev.flow.R import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.ui.components.FeedGridLayout import io.github.aedev.flow.ui.components.shared.card.VideoCardDefaults -import io.github.aedev.flow.ui.components.shared.card.rememberCardTitle +import io.github.aedev.flow.ui.components.rememberDeArrowResult +import io.github.aedev.flow.ui.components.shared.card.LocalVideoCardPreferences +import io.github.aedev.flow.ui.components.shared.card.rememberOriginalTitle import io.github.aedev.flow.ui.components.shared.quickactions.VideoQuickActionsBottomSheet import io.github.aedev.flow.ui.components.shared.quickactions.sharedQuickActionsViewModel import io.github.aedev.flow.ui.theme.extendedColors @@ -72,6 +74,11 @@ fun MediaShortCard( ) { var showQuickActions by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } + val preferences = LocalVideoCardPreferences.current + // Title only: DeArrow frames are landscape, and a reel poster is portrait. + val deArrowTitle = rememberDeArrowResult(video, preferences.deArrowEnabled, useRandomFrame = false)?.title + val originalTitle = rememberOriginalTitle(video.id, preferences.originalTitlesEnabled && deArrowTitle == null) + val title = deArrowTitle ?: originalTitle ?: video.title Column( modifier = modifier @@ -104,7 +111,7 @@ fun MediaShortCard( } Spacer(modifier = Modifier.height(TitleSpacing)) Text( - text = rememberCardTitle(video), + text = title, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, @@ -128,6 +135,7 @@ fun MediaShortCard( val quickActions = sharedQuickActionsViewModel() VideoQuickActionsBottomSheet( video = video, + title = title, onDismiss = { showQuickActions = false }, onRemoveFromCollection = { quickActions.removeFromSavedShorts(video) }.takeIf { removableFromSavedShorts }, removeFromCollectionLabel = stringResource(R.string.remove_from_saved_shorts).takeIf { removableFromSavedShorts }, From 4df6a4beeec86a2b6c40aa6d75672394fe92faa2 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:18 +0300 Subject: [PATCH 09/17] fix(music): route every shuffle through one queue shuffle --- .../flow/data/local/PlayerPreferences.kt | 12 ++++++ .../flow/player/EnhancedMusicPlayerManager.kt | 25 ++++++++---- .../aedev/flow/player/PlaylistQueueOrder.kt | 32 +++++++++++++++ .../aedev/flow/player/RestoredMusicQueue.kt | 2 - .../aedev/flow/service/Media3MusicService.kt | 29 ++++++++++++-- .../flow/player/PlaylistQueueOrderTest.kt | 40 +++++++++++++++++++ .../service/MusicPlaybackResumptionTest.kt | 1 - 7 files changed, 128 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 54d7bf2d8d..b568e69463 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -326,6 +326,7 @@ class PlayerPreferences( val MUSIC_VIDEO_SWITCH = booleanPreferencesKey("music_video_switch") val ANIMATED_ARTWORK = booleanPreferencesKey("animated_artwork") val MUSIC_REPEAT_MODE = intPreferencesKey("music_repeat_mode") + val MUSIC_SHUFFLE_ENABLED = booleanPreferencesKey("music_shuffle_enabled") val PAUSE_MUSIC_WHEN_MUTED = booleanPreferencesKey("pause_music_when_muted") val ANIMATED_ARTWORK_WIFI_ONLY = booleanPreferencesKey("animated_artwork_wifi_only") val AUTO_DOWNLOAD_LIKED_MUSIC = booleanPreferencesKey("auto_download_liked_music") @@ -2216,6 +2217,17 @@ class PlayerPreferences( } } + val musicShuffleEnabled: Flow = + context.playerPreferencesDataStore.data + .map { preferences -> preferences[Keys.MUSIC_SHUFFLE_ENABLED] ?: false } + .distinctUntilChanged() + + suspend fun setMusicShuffleEnabled(enabled: Boolean) { + context.playerPreferencesDataStore.edit { preferences -> + preferences[Keys.MUSIC_SHUFFLE_ENABLED] = enabled + } + } + val animatedArtwork: Flow = context.playerPreferencesDataStore.data .map { preferences -> preferences[Keys.ANIMATED_ARTWORK] ?: false } diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt index 728ced5037..5c753af437 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt @@ -297,8 +297,9 @@ object EnhancedMusicPlayerManager { } private fun setupPlayerListener(controller: Player) { - // The service owns repeat; the button shows what the player will actually do. + // The service owns repeat and shuffle; the buttons show what the player will actually do. _repeatMode.value = RepeatMode.fromPlayer(controller.repeatMode) + _shuffleEnabled.value = controller.shuffleModeEnabled controller.addListener( object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { @@ -338,6 +339,13 @@ object EnhancedMusicPlayerManager { _repeatMode.value = RepeatMode.fromPlayer(repeatMode) } + // Every shuffle, from the app, the card, a headset or the car, lands here. + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + val turnedOn = shuffleModeEnabled && !_shuffleEnabled.value + _shuffleEnabled.value = shuffleModeEnabled + if (turnedOn) shuffleQueue() else triggerQueueSave() + } + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { Log.e("EnhancedMusicPlayer", "Player error: ${error.errorCodeName} (${error.errorCode})", error) showPlaybackWarning( @@ -788,9 +796,14 @@ object EnhancedMusicPlayerManager { Log.d("EnhancedMusicPlayer", "Restoring saved queue: ${restored.queue.size} tracks") - _queue.value = restored.queue + // The service may have rearranged a resumed queue (a shuffle while the app was closed). + val playerOrder = controller?.let { c -> List(c.mediaItemCount) { c.getMediaItemAt(it).mediaId } } + _queue.value = + playerOrder + ?.takeIf { resumedBySystem } + ?.let { order -> PlaylistQueueOrder.alignTo(restored.queue, order) { it.videoId } } + ?: restored.queue _currentQueueIndex.value = restored.index - _shuffleEnabled.value = restored.shuffleEnabled _automixItems.value = restored.automix if (controller != null && loadedItem != null) { @@ -940,10 +953,8 @@ object EnhancedMusicPlayerManager { */ fun toggleShuffle() { scope.launch { - val enabling = !_shuffleEnabled.value - player?.shuffleModeEnabled = false - _shuffleEnabled.value = enabling - if (enabling) shuffleQueue() + val controller = player ?: return@launch + controller.shuffleModeEnabled = !controller.shuffleModeEnabled } } diff --git a/app/src/main/java/io/github/aedev/flow/player/PlaylistQueueOrder.kt b/app/src/main/java/io/github/aedev/flow/player/PlaylistQueueOrder.kt index 087eb3f55d..7b466c6699 100644 --- a/app/src/main/java/io/github/aedev/flow/player/PlaylistQueueOrder.kt +++ b/app/src/main/java/io/github/aedev/flow/player/PlaylistQueueOrder.kt @@ -98,4 +98,36 @@ internal object PlaylistQueueOrder { ): Int = items.indexOf(target).takeIf { it >= 0 } ?: items.indexOfFirst { keySelector(it) == keySelector(target) } + + /** + * The `moveMediaItem(from, to)` calls, applied in order, that turn [current] into [target], or + * null when the two don't hold the same items. + */ + fun movesTo( + current: List, + target: List, + ): List>? { + if (current.groupingBy { it }.eachCount() != target.groupingBy { it }.eachCount()) return null + val working = current.toMutableList() + val moves = mutableListOf>() + target.forEachIndexed { toIndex, key -> + val fromIndex = (toIndex until working.size).first { working[it] == key } + if (fromIndex != toIndex) { + working.add(toIndex, working.removeAt(fromIndex)) + moves += fromIndex to toIndex + } + } + return moves + } + + /** [items] put in the order of [order], or null when the two don't hold the same items. */ + fun alignTo( + items: List, + order: List, + keySelector: (T) -> K, + ): List? { + if (items.size != order.size) return null + val byKey = items.groupBy(keySelector).mapValues { (_, matches) -> ArrayDeque(matches) } + return order.map { key -> byKey[key]?.removeFirstOrNull() ?: return null } + } } diff --git a/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt b/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt index d426aa4691..2b6c7faa43 100644 --- a/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt +++ b/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt @@ -8,7 +8,6 @@ internal data class RestoredMusicQueue( val queue: List, val index: Int, val track: MusicTrack?, - val shuffleEnabled: Boolean, val automix: List, /** Where the restored track picks up; 0 starts it over. */ val resumePositionMs: Long, @@ -29,7 +28,6 @@ internal fun restoredMusicQueue(saved: QueuePersistence.QueueState): RestoredMus queue = saved.queue, index = saved.currentIndex.coerceIn(0, saved.queue.size - 1), track = track, - shuffleEnabled = saved.shuffleEnabled, automix = saved.automix, resumePositionMs = if (track != null && resumesInside) position else 0L, ) diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index 099d9ab0ae..c1ee84032c 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -27,6 +27,7 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder import androidx.media3.exoplayer.util.EventLogger import androidx.media3.session.CommandButton import androidx.media3.session.DefaultMediaNotificationProvider @@ -63,6 +64,7 @@ import io.github.aedev.flow.player.MusicPlaybackDiagnostics import io.github.aedev.flow.player.MusicPlaybackRecoveryPlanner import io.github.aedev.flow.player.MusicQueuePlanner import io.github.aedev.flow.player.MusicRadioPlanner +import io.github.aedev.flow.player.PlaylistQueueOrder import io.github.aedev.flow.player.audio.AudioSessionRegistry import io.github.aedev.flow.player.audio.eq.EqualizerAudioProcessor import io.github.aedev.flow.player.audio.shouldHandleAudioFocus @@ -153,6 +155,7 @@ class Media3MusicService : MediaLibraryService() { .setUsage(C.USAGE_MEDIA) .build() private var handlesAudioFocus = true + private var applyingSavedShuffle = false private lateinit var connectivityObserver: NetworkConnectivityObserver private var wakeLock: PowerManager.WakeLock? = null @@ -308,9 +311,14 @@ class Media3MusicService : MediaLibraryService() { initializePlayer() initializeSession() observeEqualizer() - // The service, not a saved queue, owns repeat: it outlives the app's process and is saved - // the moment it changes, so a restart or a new song keeps it. - lifecycleScope.launch { player.repeatMode = prefs.musicRepeatMode.first() } + // The service, not a saved queue, owns repeat and shuffle: it outlives the app's process and + // saves them the moment they change, so a restart or a new song keeps them. + lifecycleScope.launch { + player.repeatMode = prefs.musicRepeatMode.first() + applyingSavedShuffle = true + player.shuffleModeEnabled = prefs.musicShuffleEnabled.first() + applyingSavedShuffle = false + } videoSwitch.attach(lifecycleScope, prefs.musicVideoSwitch) lifecycleScope.launch { @@ -426,11 +434,19 @@ class Media3MusicService : MediaLibraryService() { player.setOffloadEnabled(!equalizerRepository.needsProcessing.value) setVideoDecoding(false) + // Shuffle rearranges the queue itself, so the list on screen is the play order. With this + // order, the player's shuffle flag reorders nothing on its own, whichever controller sets it. + player.setShuffleOrder(ShuffleOrder.UnshuffledShuffleOrder(0)) player.addListener( object : Player.Listener { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { updateNotification() + lifecycleScope.launch { prefs.setMusicShuffleEnabled(shuffleModeEnabled) } + // A connected app rearranges the queue it shows; without one (a media button + // started the service), this order is the only one, so rearrange it here. + val appConnected = io.github.aedev.flow.player.EnhancedMusicPlayerManager.playerInstance.value != null + if (shuffleModeEnabled && !applyingSavedShuffle && !appConnected) shuffleFromCurrentItem() } override fun onRepeatModeChanged(repeatMode: Int) { @@ -873,6 +889,13 @@ class Media3MusicService : MediaLibraryService() { player.play() } + private fun shuffleFromCurrentItem() { + val order = List(player.mediaItemCount) { it } + if (order.size < 2) return + val target = PlaylistQueueOrder.shuffleFromCurrent(order, player.currentMediaItemIndex).items + PlaylistQueueOrder.movesTo(order, target)?.forEach { (from, to) -> player.moveMediaItem(from, to) } + } + private fun skipPastFailedItem(failed: MusicPlaybackRecoveryPlanner.FailedItem) { val index = playerIndexOf(failed) when { diff --git a/app/src/test/java/io/github/aedev/flow/player/PlaylistQueueOrderTest.kt b/app/src/test/java/io/github/aedev/flow/player/PlaylistQueueOrderTest.kt index b5716b7322..e2377b1d67 100644 --- a/app/src/test/java/io/github/aedev/flow/player/PlaylistQueueOrderTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/PlaylistQueueOrderTest.kt @@ -114,4 +114,44 @@ class PlaylistQueueOrderTest { assertEquals(1, result.currentIndex) assertEquals("c", result.items[result.currentIndex]) } + + @Test + fun `moves turn the player's order into the target`() { + val current = listOf("a", "b", "c", "d", "e") + val target = listOf("c", "e", "a", "d", "b") + + val moves = PlaylistQueueOrder.movesTo(current, target)!! + val applied = current.toMutableList() + moves.forEach { (from, to) -> applied.add(to, applied.removeAt(from)) } + + assertEquals(target, applied) + } + + @Test + fun `moves handle repeated items and refuse different ones`() { + val moves = PlaylistQueueOrder.movesTo(listOf("a", "b", "a"), listOf("a", "a", "b"))!! + val applied = mutableListOf("a", "b", "a") + moves.forEach { (from, to) -> applied.add(to, applied.removeAt(from)) } + + assertEquals(listOf("a", "a", "b"), applied) + assertEquals(null, PlaylistQueueOrder.movesTo(listOf("a", "b"), listOf("a", "c"))) + assertEquals(emptyList>(), PlaylistQueueOrder.movesTo(listOf("a", "b"), listOf("a", "b"))) + } + + @Test + fun `align follows the player's order, keeping repeats apart`() { + val first = "x" to 1 + val second = "x" to 2 + val queue = listOf(first, "y" to 3, second) + + val aligned = PlaylistQueueOrder.alignTo(queue, listOf("y", "x", "x")) { it.first } + + assertEquals(listOf("y" to 3, first, second), aligned) + } + + @Test + fun `align refuses a player holding other items`() { + assertEquals(null, PlaylistQueueOrder.alignTo(listOf("a", "b"), listOf("a", "c")) { it }) + assertEquals(null, PlaylistQueueOrder.alignTo(listOf("a", "b"), listOf("a")) { it }) + } } diff --git a/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt b/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt index 59e4a28afb..80d5718e47 100644 --- a/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt +++ b/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt @@ -26,7 +26,6 @@ class MusicPlaybackResumptionTest { queue = queue, index = index, track = track, - shuffleEnabled = false, automix = emptyList(), resumePositionMs = positionMs, ) From 089c50b4cabe51da70beef1e564a49536de84563 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:37 +0300 Subject: [PATCH 10/17] fix(music): show shuffle and repeat on the system media card --- .../io/github/aedev/flow/service/Media3MusicService.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index c1ee84032c..5e6122aeef 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -1590,6 +1590,7 @@ class Media3MusicService : MediaLibraryService() { .setDisplayName(getString(if (isLiked) R.string.unlike else R.string.like)) .setCustomIconResId(if (isLiked) R.drawable.ic_like_filled else R.drawable.ic_like) .setSessionCommand(CommandToggleLike) + .setSlots(CommandButton.SLOT_OVERFLOW) .setEnabled(true) .build() @@ -1608,6 +1609,7 @@ class Media3MusicService : MediaLibraryService() { .setDisplayName(getString(R.string.shuffle)) .setCustomIconResId(if (shuffleOn) R.drawable.ic_shuffle_on else R.drawable.ic_shuffle) .setSessionCommand(CommandToggleShuffle) + .setSlots(CommandButton.SLOT_OVERFLOW) .build() val repeatButton = @@ -1616,6 +1618,7 @@ class Media3MusicService : MediaLibraryService() { .setDisplayName(getString(R.string.repeat)) .setCustomIconResId(repeatIconResId) .setSessionCommand(CommandToggleRepeat) + .setSlots(CommandButton.SLOT_OVERFLOW) .build() val closeButton = @@ -1624,10 +1627,13 @@ class Media3MusicService : MediaLibraryService() { .setDisplayName(getString(R.string.close)) .setCustomIconResId(R.drawable.ic_close) .setSessionCommand(CommandStop) + .setSlots(CommandButton.SLOT_OVERFLOW) .setEnabled(true) .build() - mediaLibrarySession.setCustomLayout(listOf(likeButton, shuffleButton, repeatButton, closeButton)) + // The Android 13+ card shows only the first two overflow buttons next to previous and next; + // the rest stay for older notifications, Auto and Wear (#1347). + mediaLibrarySession.setMediaButtonPreferences(listOf(shuffleButton, repeatButton, likeButton, closeButton)) } @OptIn(UnstableApi::class) From baa167eece82d665b513d24dd7fccd83787b1ca1 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:13:50 +0300 Subject: [PATCH 11/17] feat(player): repeat and shuffle on the video notification --- .../flow/player/EnhancedPlayerManager.kt | 31 +++++ .../aedev/flow/player/VideoSessionButtons.kt | 124 ++++++++++++++++++ .../flow/player/VideoSessionControlsTest.kt | 43 ++++++ 3 files changed, 198 insertions(+) create mode 100644 app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt create mode 100644 app/src/test/java/io/github/aedev/flow/player/VideoSessionControlsTest.kt diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt index 7f573e6bdb..df59525bc2 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedPlayerManager.kt @@ -101,7 +101,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -373,6 +375,7 @@ class EnhancedPlayerManager private constructor() { } @Volatile private var videoMediaSession: MediaSession? = null + private var videoSessionButtonsJob: Job? = null fun getVideoMediaSession(): MediaSession? = videoMediaSession @@ -438,13 +441,31 @@ class EnhancedPlayerManager private constructor() { override fun hasPreviousMediaItem(): Boolean = this@EnhancedPlayerManager.hasPrevious() } + val controls = + VideoSessionButtons( + context = appCtx, + currentState = { _playerState.value.toVideoSessionState() }, + onLoop = { target -> + toggleLoop(target.videoLoop) + if (queue.loopEnabled != target.queueLoop) toggleQueueLoop(target.queueLoop) + }, + onShuffle = ::toggleQueueShuffle, + ) val builder = MediaSession .Builder(appCtx, sessionPlayer) .setId("flow_video_session") .setBitmapLoader(sessionArtworkBitmapLoader(appCtx)) + .setCallback(controls.callback) if (sessionActivity != null) builder.setSessionActivity(sessionActivity) videoMediaSession = builder.build() + videoSessionButtonsJob = + scope.launch { + _playerState + .map { it.toVideoSessionState() } + .distinctUntilChanged() + .collect { state -> videoMediaSession?.setMediaButtonPreferences(controls.buttons(state)) } + } Log.d(TAG, "Video MediaSession created") } catch (e: Exception) { Log.e(TAG, "Failed to create video MediaSession", e) @@ -452,6 +473,8 @@ class EnhancedPlayerManager private constructor() { } private fun releaseVideoMediaSession() { + videoSessionButtonsJob?.cancel() + videoSessionButtonsJob = null try { videoMediaSession?.release() } catch (e: Exception) { @@ -460,6 +483,14 @@ class EnhancedPlayerManager private constructor() { videoMediaSession = null } + private fun EnhancedPlayerState.toVideoSessionState() = + VideoSessionControls.State( + videoLooping = isLooping, + queueLooping = isQueueLooping, + queueShuffled = isQueueShuffled, + queueSize = queueSize, + ) + /** * Set to true while PlaybackRefocusEffect is recovering from a screen-off/on cycle. * Prevents onPlaybackStateChanged(STATE_ENDED) from skipping to the next video or diff --git a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt new file mode 100644 index 0000000000..a73b2bf8b5 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt @@ -0,0 +1,124 @@ +package io.github.aedev.flow.player + +import android.content.Context +import android.os.Bundle +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import io.github.aedev.flow.R + +/** What the video notification's repeat and shuffle buttons show and do. */ +internal object VideoSessionControls { + enum class Repeat { OFF, QUEUE, ONE } + + data class State( + val videoLooping: Boolean, + val queueLooping: Boolean, + val queueShuffled: Boolean, + val queueSize: Int, + ) { + val hasQueue: Boolean get() = queueSize > 1 + + val repeat: Repeat + get() = + when { + videoLooping -> Repeat.ONE + queueLooping && hasQueue -> Repeat.QUEUE + else -> Repeat.OFF + } + } + + data class LoopTarget( + val videoLoop: Boolean, + val queueLoop: Boolean, + ) + + /** Off, then the whole queue, then this video; a single video goes straight to looping itself. */ + fun nextLoop(state: State): LoopTarget = + when (state.repeat) { + Repeat.OFF -> if (state.hasQueue) LoopTarget(videoLoop = false, queueLoop = true) else LoopTarget(videoLoop = true, queueLoop = false) + Repeat.QUEUE -> LoopTarget(videoLoop = true, queueLoop = false) + Repeat.ONE -> LoopTarget(videoLoop = false, queueLoop = false) + } +} + +/** + * Repeat and shuffle on the video session, for the media card and older notifications. They are + * session commands because the queue lives in the app: the player only ever holds the current video. + */ +@OptIn(UnstableApi::class) +internal class VideoSessionButtons( + private val context: Context, + private val currentState: () -> VideoSessionControls.State, + private val onLoop: (VideoSessionControls.LoopTarget) -> Unit, + private val onShuffle: (Boolean) -> Unit, +) { + val callback: MediaSession.Callback = + object : MediaSession.Callback { + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult = + MediaSession.ConnectionResult + .AcceptedResultBuilder(session) + .setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(REPEAT) + .add(SHUFFLE) + .build(), + ).build() + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + val state = currentState() + when (customCommand.customAction) { + REPEAT.customAction -> onLoop(VideoSessionControls.nextLoop(state)) + SHUFFLE.customAction -> if (state.hasQueue) onShuffle(!state.queueShuffled) + else -> return super.onCustomCommand(session, controller, customCommand, args) + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + } + + fun buttons(state: VideoSessionControls.State): List { + val (repeatIcon, repeatIconRes) = + when (state.repeat) { + VideoSessionControls.Repeat.ONE -> CommandButton.ICON_REPEAT_ONE to R.drawable.ic_repeat_one_on + VideoSessionControls.Repeat.QUEUE -> CommandButton.ICON_REPEAT_ALL to R.drawable.ic_repeat_on + VideoSessionControls.Repeat.OFF -> CommandButton.ICON_REPEAT_OFF to R.drawable.ic_repeat + } + val repeat = + CommandButton + .Builder(repeatIcon) + .setDisplayName(context.getString(R.string.repeat)) + .setCustomIconResId(repeatIconRes) + .setSessionCommand(REPEAT) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build() + if (!state.hasQueue) return listOf(repeat) + val shuffle = + CommandButton + .Builder(if (state.queueShuffled) CommandButton.ICON_SHUFFLE_ON else CommandButton.ICON_SHUFFLE_OFF) + .setDisplayName(context.getString(R.string.shuffle)) + .setCustomIconResId(if (state.queueShuffled) R.drawable.ic_shuffle_on else R.drawable.ic_shuffle) + .setSessionCommand(SHUFFLE) + .setSlots(CommandButton.SLOT_OVERFLOW) + .build() + return listOf(repeat, shuffle) + } + + private companion object { + val REPEAT = SessionCommand("flow.video.REPEAT", Bundle.EMPTY) + val SHUFFLE = SessionCommand("flow.video.SHUFFLE", Bundle.EMPTY) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/VideoSessionControlsTest.kt b/app/src/test/java/io/github/aedev/flow/player/VideoSessionControlsTest.kt new file mode 100644 index 0000000000..4e96fddfca --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/VideoSessionControlsTest.kt @@ -0,0 +1,43 @@ +package io.github.aedev.flow.player + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.player.VideoSessionControls.LoopTarget +import io.github.aedev.flow.player.VideoSessionControls.Repeat +import io.github.aedev.flow.player.VideoSessionControls.State +import org.junit.Test + +class VideoSessionControlsTest { + private fun state( + videoLooping: Boolean = false, + queueLooping: Boolean = false, + queueSize: Int = 5, + ) = State(videoLooping, queueLooping, queueShuffled = false, queueSize = queueSize) + + @Test + fun `a queue cycles off, whole queue, this video, off`() { + assertThat(VideoSessionControls.nextLoop(state())).isEqualTo(LoopTarget(videoLoop = false, queueLoop = true)) + assertThat(VideoSessionControls.nextLoop(state(queueLooping = true))).isEqualTo(LoopTarget(videoLoop = true, queueLoop = false)) + assertThat(VideoSessionControls.nextLoop(state(videoLooping = true))).isEqualTo(LoopTarget(videoLoop = false, queueLoop = false)) + } + + @Test + fun `a single video toggles looping itself`() { + assertThat(VideoSessionControls.nextLoop(state(queueSize = 1))).isEqualTo(LoopTarget(videoLoop = true, queueLoop = false)) + assertThat(VideoSessionControls.nextLoop(state(videoLooping = true, queueSize = 1))) + .isEqualTo(LoopTarget(videoLoop = false, queueLoop = false)) + } + + @Test + fun `looping the video wins over looping the queue`() { + assertThat(state(videoLooping = true, queueLooping = true).repeat).isEqualTo(Repeat.ONE) + assertThat(state(queueLooping = true).repeat).isEqualTo(Repeat.QUEUE) + assertThat(state().repeat).isEqualTo(Repeat.OFF) + } + + @Test + fun `a leftover queue loop without a queue reads as off`() { + assertThat(state(queueLooping = true, queueSize = 0).repeat).isEqualTo(Repeat.OFF) + assertThat(state(queueSize = 1).hasQueue).isFalse() + assertThat(state(queueSize = 2).hasQueue).isTrue() + } +} From de63ba8a4699b2d84b13fa6f7dbde643c51ef00e Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:14:27 +0300 Subject: [PATCH 12/17] docs(changelog): resume after screen off, DeArrow frames, gapless songs, shuffle and repeat on the card --- app/src/main/assets/changelog/v3.0.1.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/assets/changelog/v3.0.1.txt b/app/src/main/assets/changelog/v3.0.1.txt index 9b7b001776..3dbe18e8f0 100644 --- a/app/src/main/assets/changelog/v3.0.1.txt +++ b/app/src/main/assets/changelog/v3.0.1.txt @@ -34,7 +34,18 @@ ANDROID TV: SEARCH, MUSIC AND SETTINGS - Downloads appear in Library with their own page, and Downloads settings are available on TV - Sync works on TV as the host device, since TVs have no camera to scan a code +PLAYBACK AND NOTIFICATIONS +- The music media card shows shuffle and repeat, and like and close stay on older Android, Auto and Wear +- Shuffle from the media card, a headset, a watch or the car now shuffles the queue you see in the app +- The video notification has repeat, and shuffle while a queue is playing +- DeArrow shows a frame from the video when nobody has submitted a thumbnail, as DeArrow itself does, with a switch to turn that off +- DeArrow titles also appear on Shorts cards + FIXES +- Fix a paused video not playing again after the screen was off for a while +- Fix a video stopping for good after a decoder error; it now recovers once, then offers Retry +- Fix a short glitch at the start of the next song when one song plays into the next +- Fix DeArrow looking a video up again every time its card scrolled back into view - Fix the music player's playback buttons not responding to a keyboard, a remote or TalkBack - Fix the TV seek bar redrawing every frame while paused - Fix Open Collective not opening from About on TV From 4ad06de0a4d75c20535313b077df6268da61e9e2 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:18:47 +0300 Subject: [PATCH 13/17] fix(music): bring shuffle back with the saved queue so a restore never reshuffles it --- .../aedev/flow/data/local/PlayerPreferences.kt | 12 ------------ .../flow/player/EnhancedMusicPlayerManager.kt | 11 +++++++++++ .../aedev/flow/player/RestoredMusicQueue.kt | 2 ++ .../aedev/flow/service/Media3MusicService.kt | 16 +++++++--------- .../flow/service/MusicPlaybackResumptionTest.kt | 1 + 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index b568e69463..54d7bf2d8d 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -326,7 +326,6 @@ class PlayerPreferences( val MUSIC_VIDEO_SWITCH = booleanPreferencesKey("music_video_switch") val ANIMATED_ARTWORK = booleanPreferencesKey("animated_artwork") val MUSIC_REPEAT_MODE = intPreferencesKey("music_repeat_mode") - val MUSIC_SHUFFLE_ENABLED = booleanPreferencesKey("music_shuffle_enabled") val PAUSE_MUSIC_WHEN_MUTED = booleanPreferencesKey("pause_music_when_muted") val ANIMATED_ARTWORK_WIFI_ONLY = booleanPreferencesKey("animated_artwork_wifi_only") val AUTO_DOWNLOAD_LIKED_MUSIC = booleanPreferencesKey("auto_download_liked_music") @@ -2217,17 +2216,6 @@ class PlayerPreferences( } } - val musicShuffleEnabled: Flow = - context.playerPreferencesDataStore.data - .map { preferences -> preferences[Keys.MUSIC_SHUFFLE_ENABLED] ?: false } - .distinctUntilChanged() - - suspend fun setMusicShuffleEnabled(enabled: Boolean) { - context.playerPreferencesDataStore.edit { preferences -> - preferences[Keys.MUSIC_SHUFFLE_ENABLED] = enabled - } - } - val animatedArtwork: Flow = context.playerPreferencesDataStore.data .map { preferences -> preferences[Keys.ANIMATED_ARTWORK] ?: false } diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt index 5c753af437..22b4bedca3 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt @@ -805,6 +805,8 @@ object EnhancedMusicPlayerManager { ?: restored.queue _currentQueueIndex.value = restored.index _automixItems.value = restored.automix + // A queue the service resumed already carries its shuffle; otherwise it comes back with it. + if (!resumedBySystem) applySavedShuffle(controller, restored.shuffleEnabled) if (controller != null && loadedItem != null) { syncCurrentTrackFromMediaItem(controller, loadedItem) @@ -951,6 +953,15 @@ object EnhancedMusicPlayerManager { * Shuffle is app-owned state, not ExoPlayer's shuffle mode: enabling it physically * rearranges the queue so the listed order always matches the playback order. */ + /** The saved queue is already in its shuffled order, so turning the flag back on must not reshuffle it. */ + private fun applySavedShuffle( + controller: Player?, + enabled: Boolean, + ) { + _shuffleEnabled.value = enabled + if (controller != null && controller.shuffleModeEnabled != enabled) controller.shuffleModeEnabled = enabled + } + fun toggleShuffle() { scope.launch { val controller = player ?: return@launch diff --git a/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt b/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt index 2b6c7faa43..d426aa4691 100644 --- a/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt +++ b/app/src/main/java/io/github/aedev/flow/player/RestoredMusicQueue.kt @@ -8,6 +8,7 @@ internal data class RestoredMusicQueue( val queue: List, val index: Int, val track: MusicTrack?, + val shuffleEnabled: Boolean, val automix: List, /** Where the restored track picks up; 0 starts it over. */ val resumePositionMs: Long, @@ -28,6 +29,7 @@ internal fun restoredMusicQueue(saved: QueuePersistence.QueueState): RestoredMus queue = saved.queue, index = saved.currentIndex.coerceIn(0, saved.queue.size - 1), track = track, + shuffleEnabled = saved.shuffleEnabled, automix = saved.automix, resumePositionMs = if (track != null && resumesInside) position else 0L, ) diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index 5e6122aeef..d37a342d46 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -311,14 +311,9 @@ class Media3MusicService : MediaLibraryService() { initializePlayer() initializeSession() observeEqualizer() - // The service, not a saved queue, owns repeat and shuffle: it outlives the app's process and - // saves them the moment they change, so a restart or a new song keeps them. - lifecycleScope.launch { - player.repeatMode = prefs.musicRepeatMode.first() - applyingSavedShuffle = true - player.shuffleModeEnabled = prefs.musicShuffleEnabled.first() - applyingSavedShuffle = false - } + // The service, not a saved queue, owns repeat: it outlives the app's process and is saved + // the moment it changes, so a restart or a new song keeps it. + lifecycleScope.launch { player.repeatMode = prefs.musicRepeatMode.first() } videoSwitch.attach(lifecycleScope, prefs.musicVideoSwitch) lifecycleScope.launch { @@ -442,7 +437,6 @@ class Media3MusicService : MediaLibraryService() { object : Player.Listener { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { updateNotification() - lifecycleScope.launch { prefs.setMusicShuffleEnabled(shuffleModeEnabled) } // A connected app rearranges the queue it shows; without one (a media button // started the service), this order is the only one, so rearrange it here. val appConnected = io.github.aedev.flow.player.EnhancedMusicPlayerManager.playerInstance.value != null @@ -1650,6 +1644,10 @@ class Media3MusicService : MediaLibraryService() { if (restored == null) { result.setException(IllegalStateException("No saved music queue to resume")) } else { + // A queue saved shuffled is already in its shuffled order. + applyingSavedShuffle = true + player.shuffleModeEnabled = restored.shuffleEnabled + applyingSavedShuffle = false result.set(musicResumptionItems(restored)) } } diff --git a/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt b/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt index 80d5718e47..59e4a28afb 100644 --- a/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt +++ b/app/src/test/java/io/github/aedev/flow/service/MusicPlaybackResumptionTest.kt @@ -26,6 +26,7 @@ class MusicPlaybackResumptionTest { queue = queue, index = index, track = track, + shuffleEnabled = false, automix = emptyList(), resumePositionMs = positionMs, ) From 473bea177cb4c8447065968aae6db44e76762528 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:23:58 +0300 Subject: [PATCH 14/17] style: format the DeArrow, Shorts card and video session changes --- .../flow/player/EnhancedMusicPlayerManager.kt | 8 +++---- .../aedev/flow/player/VideoSessionButtons.kt | 21 ++++++++++++++++--- .../ui/components/shared/MediaShortCard.kt | 2 +- .../settings/index/IntegrationsIndex.kt | 3 ++- .../repository/DeArrowBrandingPolicyTest.kt | 6 +++++- .../data/repository/DeArrowRepositoryTest.kt | 6 +++++- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt index 22b4bedca3..25c65fa6e9 100644 --- a/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt +++ b/app/src/main/java/io/github/aedev/flow/player/EnhancedMusicPlayerManager.kt @@ -949,10 +949,6 @@ object EnhancedMusicPlayerManager { } } - /** - * Shuffle is app-owned state, not ExoPlayer's shuffle mode: enabling it physically - * rearranges the queue so the listed order always matches the playback order. - */ /** The saved queue is already in its shuffled order, so turning the flag back on must not reshuffle it. */ private fun applySavedShuffle( controller: Player?, @@ -962,6 +958,10 @@ object EnhancedMusicPlayerManager { if (controller != null && controller.shuffleModeEnabled != enabled) controller.shuffleModeEnabled = enabled } + /** + * The player's shuffle flag says whether shuffle is on; turning it on physically rearranges the + * queue, so the listed order always matches the playback order. + */ fun toggleShuffle() { scope.launch { val controller = player ?: return@launch diff --git a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt index a73b2bf8b5..e5453d61a2 100644 --- a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt +++ b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt @@ -41,9 +41,24 @@ internal object VideoSessionControls { /** Off, then the whole queue, then this video; a single video goes straight to looping itself. */ fun nextLoop(state: State): LoopTarget = when (state.repeat) { - Repeat.OFF -> if (state.hasQueue) LoopTarget(videoLoop = false, queueLoop = true) else LoopTarget(videoLoop = true, queueLoop = false) - Repeat.QUEUE -> LoopTarget(videoLoop = true, queueLoop = false) - Repeat.ONE -> LoopTarget(videoLoop = false, queueLoop = false) + Repeat.OFF -> { + if (state.hasQueue) { + LoopTarget( + videoLoop = false, + queueLoop = true, + ) + } else { + LoopTarget(videoLoop = true, queueLoop = false) + } + } + + Repeat.QUEUE -> { + LoopTarget(videoLoop = true, queueLoop = false) + } + + Repeat.ONE -> { + LoopTarget(videoLoop = false, queueLoop = false) + } } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt index 555734432a..41d705d649 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaShortCard.kt @@ -32,9 +32,9 @@ import androidx.compose.ui.unit.dp import io.github.aedev.flow.R import io.github.aedev.flow.data.model.Video import io.github.aedev.flow.ui.components.FeedGridLayout -import io.github.aedev.flow.ui.components.shared.card.VideoCardDefaults import io.github.aedev.flow.ui.components.rememberDeArrowResult import io.github.aedev.flow.ui.components.shared.card.LocalVideoCardPreferences +import io.github.aedev.flow.ui.components.shared.card.VideoCardDefaults import io.github.aedev.flow.ui.components.shared.card.rememberOriginalTitle import io.github.aedev.flow.ui.components.shared.quickactions.VideoQuickActionsBottomSheet import io.github.aedev.flow.ui.components.shared.quickactions.sharedQuickActionsViewModel diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt index 20c889635f..9ef75ffc71 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/IntegrationsIndex.kt @@ -92,5 +92,6 @@ internal object IntegrationsIndex { destination = page, ) - val all = listOf(sponsorBlock, segments, contribute, userId, deArrow, deArrowRandomFrame, deArrowBadge, dislikes, discord, discordAccount) + val all = + listOf(sponsorBlock, segments, contribute, userId, deArrow, deArrowRandomFrame, deArrowBadge, dislikes, discord, discordAccount) } diff --git a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt index d4cd40b25c..0ead98ca35 100644 --- a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowBrandingPolicyTest.kt @@ -53,7 +53,11 @@ class DeArrowBrandingPolicyTest { val content = DeArrowContent( titles = listOf(DeArrowTitle("Locked title", votes = 1, locked = true), DeArrowTitle("Popular title", votes = 9)), - thumbnails = listOf(DeArrowThumbnail(timestamp = 3.5, votes = 2, locked = true), DeArrowThumbnail(timestamp = 80.0, votes = 7)), + thumbnails = + listOf( + DeArrowThumbnail(timestamp = 3.5, votes = 2, locked = true), + DeArrowThumbnail(timestamp = 80.0, votes = 7), + ), randomTime = 0.5, videoDuration = 100.0, ) diff --git a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt index e8aeea90c7..f4447eac74 100644 --- a/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/repository/DeArrowRepositoryTest.kt @@ -73,7 +73,11 @@ class DeArrowRepositoryTest { fun `cards asking at the same time share one request`() = runBlocking { val gate = CountDownLatch(1) - val deArrow = repository(gate) { 200 to """{"titles":[{"title":"t","votes":1,"locked":false,"original":false}],"thumbnails":[]}""" } + val deArrow = + repository(gate) { + 200 to + """{"titles":[{"title":"t","votes":1,"locked":false,"original":false}],"thumbnails":[]}""" + } val asks = (1..5).map { async(Dispatchers.IO) { deArrow.branding("abc123def45") } } Thread.sleep(200) From 655007cff929e780e9872452a5f4766000d1f928 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:34:07 +0300 Subject: [PATCH 15/17] refactor(player): keep the video session's default commands when adding repeat and shuffle --- .../aedev/flow/player/VideoSessionButtons.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt index e5453d61a2..7451131615 100644 --- a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt +++ b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt @@ -78,16 +78,18 @@ internal class VideoSessionButtons( override fun onConnect( session: MediaSession, controller: MediaSession.ControllerInfo, - ): MediaSession.ConnectionResult = - MediaSession.ConnectionResult - .AcceptedResultBuilder(session) - .setAvailableSessionCommands( - MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS - .buildUpon() - .add(REPEAT) - .add(SHUFFLE) - .build(), - ).build() + ): MediaSession.ConnectionResult { + val defaults = super.onConnect(session, controller) + if (!defaults.isAccepted) return defaults + return MediaSession.ConnectionResult.accept( + defaults.availableSessionCommands + .buildUpon() + .add(REPEAT) + .add(SHUFFLE) + .build(), + defaults.availablePlayerCommands, + ) + } override fun onCustomCommand( session: MediaSession, From 8de085a8ab10c969eecfb202056a83b7d2e79c11 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:59:29 +0300 Subject: [PATCH 16/17] fix(dearrow): hide the badge when the frame is not ready and YouTube's thumbnail shows --- .../flow/ui/components/shared/MediaThumbnail.kt | 2 ++ .../ui/components/shared/VideoThumbnailImage.kt | 13 ++++++++++++- .../ui/components/shared/card/VideoCardState.kt | 8 ++++++-- .../ui/components/shared/card/VideoCardThumbnail.kt | 9 ++++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaThumbnail.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaThumbnail.kt index 7ff23cad8a..f4938fd525 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaThumbnail.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/MediaThumbnail.kt @@ -43,6 +43,7 @@ fun MediaThumbnail( durationSeconds: Int? = null, showWatchProgress: Boolean = false, placeholder: ImageVector? = null, + onModelLoaded: ((Any) -> Unit)? = null, overlay: @Composable BoxScope.() -> Unit = {}, ) { val openOrigin = rememberMediaOpenOrigin(videoId, shape) @@ -76,6 +77,7 @@ fun MediaThumbnail( modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, onImageKey = reportImageKey, + onModelLoaded = onModelLoaded, ) if (durationSeconds != null && durationSeconds > 0) { diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/VideoThumbnailImage.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/VideoThumbnailImage.kt index 76eec63a41..977abc6b1d 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/VideoThumbnailImage.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/VideoThumbnailImage.kt @@ -39,6 +39,7 @@ fun VideoThumbnailImage( portrait: Boolean = false, placeholderKey: MemoryCache.Key? = null, onImageKey: ((MemoryCache.Key?) -> Unit)? = null, + onModelLoaded: ((Any) -> Unit)? = null, ) { val quality = LocalThumbnailQuality.current val models = @@ -67,6 +68,7 @@ fun VideoThumbnailImage( contentScale = contentScale, placeholderKey = placeholderKey, onImageKey = onImageKey, + onModelLoaded = onModelLoaded, ) } @@ -86,6 +88,7 @@ private fun SafeAsyncImage( contentScale: ContentScale = ContentScale.Fit, placeholderKey: MemoryCache.Key? = null, onImageKey: ((MemoryCache.Key?) -> Unit)? = null, + onModelLoaded: ((Any) -> Unit)? = null, ) { var index by remember(models) { mutableStateOf(0) } val currentModel = models.getOrNull(index) @@ -120,7 +123,15 @@ private fun SafeAsyncImage( contentDescription = contentDescription, modifier = modifier, contentScale = contentScale, - onSuccess = onImageKey?.let { report -> { state -> report(state.result.memoryCacheKey) } }, + onSuccess = + if (onImageKey == null && onModelLoaded == null) { + null + } else { + { state -> + onImageKey?.invoke(state.result.memoryCacheKey) + onModelLoaded?.invoke(currentModel) + } + }, onError = { index = if (index < models.lastIndex) index + 1 else models.size }, diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt index 74ef24c410..61a23418b5 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardState.kt @@ -216,7 +216,9 @@ internal class VideoCardState( val video: Video, val title: String, val thumbnailUrl: String, - val showDeArrowBadge: Boolean, + val deArrowTitle: Boolean, + val deArrowFrameUrl: String?, + val deArrowBadgeEnabled: Boolean, val channelName: String, val collaborators: List, val watchProgress: Float?, @@ -250,7 +252,9 @@ internal fun rememberVideoCardState(video: Video): VideoCardState { video = video, title = deArrow?.title ?: originalTitle ?: video.title, thumbnailUrl = deArrow?.thumbnailUrl ?: video.thumbnailUrl, - showDeArrowBadge = deArrow != null && preferences.deArrowBadgeEnabled, + deArrowTitle = deArrow?.title != null, + deArrowFrameUrl = deArrow?.thumbnailUrl, + deArrowBadgeEnabled = preferences.deArrowBadgeEnabled, channelName = channelName, collaborators = collaborators, watchProgress = watchProgress, diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardThumbnail.kt b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardThumbnail.kt index 530c9c0719..03dc3b8b02 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardThumbnail.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/shared/card/VideoCardThumbnail.kt @@ -5,6 +5,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AutoFixHigh import androidx.compose.material.icons.rounded.NotificationsActive import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape @@ -31,12 +35,15 @@ internal fun VideoCardThumbnail( width: Dp = Dp.Unspecified, ) { val video = state.video + // DeArrow answers 204 for a frame it has not rendered yet, and the image falls back to YouTube's. + var deArrowFrameShown by remember(state.deArrowFrameUrl) { mutableStateOf(false) } MediaThumbnail( videoId = video.id, thumbnailUrl = state.thumbnailUrl, width = width, shape = shape, showWatchProgress = LocalVideoCardPreferences.current.showWatchProgress, + onModelLoaded = state.deArrowFrameUrl?.let { frame -> { loaded -> deArrowFrameShown = loaded == frame } }, modifier = modifier, ) { val badgeModifier = Modifier.padding(MediaThumbnailDefaults.BadgePadding) @@ -53,7 +60,7 @@ internal fun VideoCardThumbnail( modifier = badgeModifier.align(Alignment.TopStart), ) } - if (state.showDeArrowBadge) { + if (state.deArrowBadgeEnabled && (state.deArrowTitle || deArrowFrameShown)) { MediaIconBadge( icon = Icons.Outlined.AutoFixHigh, contentDescription = stringResource(R.string.dearrow_badge), From 29a08e3dd47d207b3b820e3d0e788137d81293dc Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:15:18 +0300 Subject: [PATCH 17/17] fix(player): give video session controllers the trusted default commands again --- .../aedev/flow/player/VideoSessionButtons.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt index 7451131615..bb7354a8f0 100644 --- a/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt +++ b/app/src/main/java/io/github/aedev/flow/player/VideoSessionButtons.kt @@ -75,20 +75,22 @@ internal class VideoSessionButtons( ) { val callback: MediaSession.Callback = object : MediaSession.Callback { - override fun onConnect( + // Built from the trust-aware defaults: in Media3 1.11 super.onConnect is an empty placeholder. + override fun onConnectAsync( session: MediaSession, controller: MediaSession.ControllerInfo, - ): MediaSession.ConnectionResult { - val defaults = super.onConnect(session, controller) - if (!defaults.isAccepted) return defaults - return MediaSession.ConnectionResult.accept( - defaults.availableSessionCommands - .buildUpon() - .add(REPEAT) - .add(SHUFFLE) - .build(), - defaults.availablePlayerCommands, - ) + ): ListenableFuture { + val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session, controller) + if (controller.isTrusted) { + result.setAvailableSessionCommands( + MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS + .buildUpon() + .add(REPEAT) + .add(SHUFFLE) + .build(), + ) + } + return Futures.immediateFuture(result.build()) } override fun onCustomCommand(