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(