diff --git a/.github/nightly-notes/2026-11-03-fix-librefm-live-quality-resume-progress-bar.md b/.github/nightly-notes/2026-11-03-fix-librefm-live-quality-resume-progress-bar.md new file mode 100644 index 0000000000..eae50363f8 --- /dev/null +++ b/.github/nightly-notes/2026-11-03-fix-librefm-live-quality-resume-progress-bar.md @@ -0,0 +1,7 @@ +## New +- Player appearance: choose whether the thin progress bar sits on the video, just below it, or stays off while the controls are hidden (#1550) + +## Fixed +- Scrobbling: signing in to Libre.fm works (#1658) +- Auto quality and live streams can go above 1080p again on phones with a higher resolution screen (#1617) +- A paused video that comes back black after leaving the app resumes from where it stopped, and changing quality keeps your place (#1527) diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerOverlayPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerOverlayPreferences.kt index b311b6472b..63bd00da59 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerOverlayPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerOverlayPreferences.kt @@ -26,6 +26,7 @@ data class PlayerOverlayPreferences( defaultPaddingDp = DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP, maxPaddingDp = MAX_PORTRAIT_SEEKBAR_PADDING_DP, ), + val idleProgressBar: IdleProgressBar = IdleProgressBar.ON_VIDEO, val scrubPreviewStyle: ScrubPreviewStyle = ScrubPreviewStyle.STRIP, /** Off by default: two more buttons in the transport row is a cost every video pays. */ val frameStepButtonsEnabled: Boolean = false, 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 ecfa7c2826..2e8882bebb 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 @@ -449,6 +449,7 @@ class PlayerPreferences( // Fullscreen Player val PORTRAIT_SEEKBAR_PADDING_MODE = device(stringPreferencesKey("portrait_seekbar_padding_mode")) val PORTRAIT_SEEKBAR_CUSTOM_PADDING_DP = device(intPreferencesKey("portrait_seekbar_custom_padding_dp")) + val PORTRAIT_IDLE_PROGRESS_BAR = device(stringPreferencesKey("portrait_idle_progress_bar")) val FULLSCREEN_SEEKBAR_PADDING_MODE = device(stringPreferencesKey("fullscreen_seekbar_padding_mode")) val FULLSCREEN_SEEKBAR_CUSTOM_PADDING_DP = device(intPreferencesKey("fullscreen_seekbar_custom_padding_dp")) val FRAME_STEP_BUTTONS_ENABLED = device(booleanPreferencesKey("frame_step_buttons_enabled")) @@ -782,6 +783,7 @@ class PlayerPreferences( defaultPaddingDp = DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP, maxPaddingDp = MAX_PORTRAIT_SEEKBAR_PADDING_DP, ), + idleProgressBar = resolveIdleProgressBar(device[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR]), scrubPreviewStyle = resolveScrubPreviewStyle(profile[Keys.SCRUB_PREVIEW_STYLE]), frameStepButtonsEnabled = device[DeviceKeys.FRAME_STEP_BUTTONS_ENABLED] ?: overlayDefaults.frameStepButtonsEnabled, @@ -2075,6 +2077,16 @@ class PlayerPreferences( } } + val idleProgressBar: Flow = + context.devicePreferencesDataStore.data + .map { preferences -> resolveIdleProgressBar(preferences[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR]) } + + suspend fun setIdleProgressBar(position: IdleProgressBar) { + context.devicePreferencesDataStore.edit { preferences -> + preferences[DeviceKeys.PORTRAIT_IDLE_PROGRESS_BAR] = position.name + } + } + val fullscreenSeekbarPaddingMode: Flow = context.devicePreferencesDataStore.data .map { preferences -> @@ -4296,6 +4308,16 @@ enum class SeekbarPaddingMode { CUSTOM, } +/** Where the portrait player draws its thin progress bar while the controls are hidden (#1550). */ +enum class IdleProgressBar { + ON_VIDEO, + BELOW_VIDEO, + OFF, +} + +internal fun resolveIdleProgressBar(stored: String?): IdleProgressBar = + stored?.let { value -> IdleProgressBar.entries.firstOrNull { it.name == value } } ?: IdleProgressBar.ON_VIDEO + /** What a scrub shows above the bar: a filmstrip around the target, or the single frame under it. */ enum class ScrubPreviewStyle { STRIP, diff --git a/app/src/main/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClient.kt b/app/src/main/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClient.kt index 8e47e4f655..b8531e9326 100644 --- a/app/src/main/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClient.kt +++ b/app/src/main/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClient.kt @@ -15,6 +15,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.io.IOException import java.security.MessageDigest +import java.util.Locale /** An app's identity on a Last.fm-compatible service. */ data class AudioscrobblerKeys( @@ -24,6 +25,15 @@ data class AudioscrobblerKeys( val isUsable: Boolean get() = apiKey.isNotBlank() && secret.isNotBlank() } +/** How a service takes the credentials for auth.getMobileSession. */ +enum class MobileSessionAuth { + /** Last.fm: the password itself, inside the signed request. */ + PASSWORD, + + /** Libre.fm (GNU FM): only an auth token derived from the user name and password. */ + AUTH_TOKEN, +} + /** * The Audioscrobbler 2.0 API that Last.fm and Libre.fm both speak: a mobile session from a user * name and password, now playing, and scrobbles in batches of up to [MAX_BATCH]. @@ -31,6 +41,7 @@ data class AudioscrobblerKeys( class AudioscrobblerClient( private val baseUrl: String, private val http: () -> OkHttpClient, + private val sessionAuth: MobileSessionAuth = MobileSessionAuth.PASSWORD, ) { suspend fun signIn( userName: String, @@ -39,7 +50,12 @@ class AudioscrobblerClient( ): Result = withContext(Dispatchers.IO) { runCatching { - val body = post(mapOf("method" to "auth.getMobileSession", "username" to userName, "password" to password), keys) + val credential = + when (sessionAuth) { + MobileSessionAuth.PASSWORD -> "password" to password + MobileSessionAuth.AUTH_TOKEN -> "authToken" to audioscrobblerAuthToken(userName, password) + } + val body = post(mapOf("method" to "auth.getMobileSession", "username" to userName, credential), keys) body.errorCode()?.let { throw IOException(body.errorMessage() ?: "Error $it") } val session = body["session"]?.jsonObject ?: throw IOException("No session") ScrobbleAccount( @@ -243,6 +259,12 @@ class AudioscrobblerClient( } } +/** GNU FM's token: MD5 of the lower-cased user name followed by the hex MD5 of the password. */ +internal fun audioscrobblerAuthToken( + userName: String, + password: String, +): String = md5Hex(userName.lowercase(Locale.ROOT) + md5Hex(password)) + /** The documented api_sig: every parameter but format and callback, sorted, joined, then the secret, MD5'd. */ internal fun audioscrobblerSignature( params: Map, @@ -254,11 +276,14 @@ internal fun audioscrobblerSignature( .toSortedMap() .entries .joinToString("") { it.key + it.value } + secret - return MessageDigest + return md5Hex(base) +} + +private fun md5Hex(text: String): String = + MessageDigest .getInstance("MD5") - .digest(base.toByteArray(Charsets.UTF_8)) + .digest(text.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } -} /** Session and key errors sign the account out; outages and rate limits retry; anything else drops the batch. */ internal fun outcomeOf(errorCode: Int?): SendOutcome = diff --git a/app/src/main/java/io/github/aedev/flow/data/scrobble/Scrobbler.kt b/app/src/main/java/io/github/aedev/flow/data/scrobble/Scrobbler.kt index af0fa0b01a..a911259e6e 100644 --- a/app/src/main/java/io/github/aedev/flow/data/scrobble/Scrobbler.kt +++ b/app/src/main/java/io/github/aedev/flow/data/scrobble/Scrobbler.kt @@ -37,7 +37,7 @@ class Scrobbler suspend fun awaitWrites() = work.children.toList().joinAll() private val lastFm = AudioscrobblerClient(AudioscrobblerClient.LASTFM_URL, http::get) - private val libreFm = AudioscrobblerClient(AudioscrobblerClient.LIBREFM_URL, http::get) + private val libreFm = AudioscrobblerClient(AudioscrobblerClient.LIBREFM_URL, http::get, MobileSessionAuth.AUTH_TOKEN) private val listenBrainz = ListenBrainzClient(http::get) /** The key Last.fm calls are signed with: the viewer's own when they turned it on, else the build's. */ 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 4579f2fbae..1c4f4340cc 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 @@ -665,6 +665,7 @@ class EnhancedPlayerManager private constructor() { onStreamExpired = { onStreamsDenied() }, onPlaybackAbandoned = { if (!skipAbandonedVideo()) scope.launch { _playbackAbandonedEvent.emit(Unit) } }, onDecoderGaveUp = { scope.launch { _decoderFailureEvent.emit(Unit) } }, + rememberPlaybackForRecovery = { player?.let { captureClearedMediaRecovery(it, restoresOnForeground = false) } }, onGatedCodecFallback = { position -> qualityManager?.fallbackToAlternateCodec(position) ?: false }, getFailedStreamUrls = { qualityManager?.let { qm -> @@ -2410,8 +2411,8 @@ class EnhancedPlayerManager private constructor() { restoreVideoOutput() } val p = player ?: return + if (reloadClearedMediaIfNeeded(playWhenReadyOverride = true)) return if (p.playbackState == Player.STATE_IDLE) { - if (reloadClearedMediaIfNeeded(playWhenReadyOverride = true)) return if (p.mediaItemCount > 0) { Log.d(TAG, "play(): player IDLE with media — calling prepare()") p.prepare() @@ -2420,12 +2421,20 @@ class EnhancedPlayerManager private constructor() { p.play() } - fun recoverClearedMediaAfterForeground(): Boolean = reloadClearedMediaIfNeeded() + fun recoverClearedMediaAfterForeground(): Boolean = reloadClearedMediaIfNeeded(automatic = true) - private fun reloadClearedMediaIfNeeded(playWhenReadyOverride: Boolean? = null): Boolean { + /** Reloads an emptied player at its saved place, without extracting the video again. */ + fun retryClearedMedia(): Boolean = reloadClearedMediaIfNeeded(playWhenReadyOverride = true) + + private fun reloadClearedMediaIfNeeded( + playWhenReadyOverride: Boolean? = null, + automatic: Boolean = false, + ): Boolean { val p = player ?: return false - if (p.playbackState != Player.STATE_IDLE || p.mediaItemCount > 0) return false + // Empty is the signal, not IDLE: a prepare() on an emptied player moves it to ENDED. + if (p.mediaItemCount > 0) return false val recovery = clearedMediaRecoveryState.pendingFor(currentVideoId) ?: return false + if (automatic && !recovery.restoresOnForeground) return false val resumePosition = recovery.positionMs.takeIf { it > 0L } val shouldPlay = playWhenReadyOverride ?: recovery.playWhenReady Log.w( @@ -2787,7 +2796,7 @@ class EnhancedPlayerManager private constructor() { if (currentIsLiveStream) { switchLiveQuality(height) } else { - qualityManager?.switchQualityByHeight(height, player?.currentPosition ?: 0L) + qualityManager?.switchQualityByHeight(height, qualitySwitchPositionMs()) } fun switchQuality(height: Int) = switchQualityByHeight(height) @@ -2797,7 +2806,7 @@ class EnhancedPlayerManager private constructor() { return switchSabrQuality(option) } if (currentIsLiveStream) return switchLiveQuality(option.height) - return qualityManager?.switchQuality(option, player?.currentPosition ?: 0L) + return qualityManager?.switchQuality(option, qualitySwitchPositionMs()) } private fun updateLiveQualityOptions(tracks: Tracks) { @@ -3151,19 +3160,35 @@ class EnhancedPlayerManager private constructor() { if (shouldKeepPlaying) { switchToAudioOnly() } else { - if (p.mediaItemCount > 0) { - clearedMediaRecoveryState.capture( - videoId = currentVideoId, - positionMs = p.currentPosition, - playWhenReady = p.playWhenReady, - localFilePath = currentLocalFilePath, - ) - } + captureClearedMediaRecovery(p) p.stop() p.clearMediaItems() } } + private fun captureClearedMediaRecovery( + p: ExoPlayer, + restoresOnForeground: Boolean = true, + ) { + if (p.mediaItemCount == 0) return + clearedMediaRecoveryState.capture( + videoId = currentVideoId, + positionMs = p.currentPosition, + playWhenReady = p.playWhenReady, + localFilePath = currentLocalFilePath, + restoresOnForeground = restoresOnForeground, + ) + } + + private fun qualitySwitchPositionMs(): Long { + val p = player ?: return 0L + return clearedMediaRecoveryState.takeReloadPosition( + videoId = currentVideoId, + playerEmpty = p.mediaItemCount == 0, + currentPositionMs = p.currentPosition, + ) + } + fun switchToAudioOnly() { val p = player ?: return autoNextLog("switchToAudioOnly") @@ -3488,9 +3513,11 @@ class EnhancedPlayerManager private constructor() { fun handleRefocusStuck(videoId: String?) { val p = player ?: return if (isRecoveringStreams(videoId) || hasAbandonedPlayback()) return - if (reloadClearedMediaIfNeeded()) { + if (reloadClearedMediaIfNeeded(automatic = true)) { return } + // Emptied by a decoder failure: the error screen's Retry owns the reload, not a resume. + if (p.mediaItemCount == 0 && clearedMediaRecoveryState.pendingFor(videoId) != null) return errorHandler?.handleRefocusStuck(p, videoId) } } diff --git a/app/src/main/java/io/github/aedev/flow/player/config/VideoSizeCap.kt b/app/src/main/java/io/github/aedev/flow/player/config/VideoSizeCap.kt index 0b6e1686e7..d528bedf13 100644 --- a/app/src/main/java/io/github/aedev/flow/player/config/VideoSizeCap.kt +++ b/app/src/main/java/io/github/aedev/flow/player/config/VideoSizeCap.kt @@ -5,7 +5,10 @@ import android.content.Context import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.trackselection.DefaultTrackSelector -/** The largest frame the track selector may pick: never above 4K, lower on a small app heap. */ +/** + * The largest frame the track selector may pick: never above 4K, and 1080p on a low-RAM device. The + * screen viewport, applied beside it, is what keeps automatic quality near the display's own size. + */ data class VideoSizeCap( val maxWidth: Int, val maxHeight: Int, @@ -13,29 +16,20 @@ data class VideoSizeCap( companion object { val UHD = VideoSizeCap(PlayerConfig.MAX_VIDEO_WIDTH, PlayerConfig.MAX_VIDEO_HEIGHT) - fun forHeap( - memoryClassMb: Int, - isLowRamDevice: Boolean, - ): VideoSizeCap = - when { - isLowRamDevice || memoryClassMb <= 256 -> VideoSizeCap(1920, 1080) - memoryClassMb <= 384 -> VideoSizeCap(2560, 1440) - else -> UHD - } + // Not keyed to memoryClass: most phones report 256 MB, and frame size does not decide playback + // memory, the buffer byte targets do. A 256 MB bucket held Auto at 1080p everywhere (#1617). + fun forRam(isLowRamDevice: Boolean): VideoSizeCap = if (isLowRamDevice) VideoSizeCap(1920, 1080) else UHD fun forDevice(context: Context): VideoSizeCap { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager - return forHeap( - memoryClassMb = activityManager?.memoryClass ?: 256, - isLowRamDevice = activityManager?.isLowRamDevice == true, - ) + return forRam(isLowRamDevice = activityManager?.isLowRamDevice == true) } } } /** * Drops every per-quality size constraint and puts the device limits back. Clearing alone would also - * lift the heap cap, and a local file clears the screen viewport, so an automatic pick could climb to + * lift the low-RAM cap, and a local file clears the screen viewport, so an automatic pick could climb to * a size the device can neither decode comfortably nor show. */ @UnstableApi 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 aebbefcf88..d5954f9d34 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 @@ -46,6 +46,7 @@ class PlayerErrorHandler( private val onStreamExpired: () -> Unit, private val onPlaybackAbandoned: () -> Unit, private val onDecoderGaveUp: () -> Unit, + private val rememberPlaybackForRecovery: () -> Unit, private val onGatedCodecFallback: (Long) -> Boolean, private val getFailedStreamUrls: () -> Set, private val markStreamFailed: (String) -> Unit, @@ -421,6 +422,8 @@ class PlayerErrorHandler( } Log.e(TAG, "Decoder error — no alternatives available, stopping playback") + // A device fault, not a broken stream: keep the place so Play, Retry and a quality change resume it. + rememberPlaybackForRecovery() onPlaybackShutdown() stateFlow.value = stateFlow.value.copy( diff --git a/app/src/main/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryState.kt b/app/src/main/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryState.kt index 64a47f9a89..7ffc8440df 100644 --- a/app/src/main/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryState.kt +++ b/app/src/main/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryState.kt @@ -4,7 +4,9 @@ internal data class ClearedMediaRecoverySnapshot( val videoId: String, val positionMs: Long, val playWhenReady: Boolean, - val localFilePath: String? + val localFilePath: String?, + /** False when only an explicit action (Play, Retry) may reload it, as after a decoder failure. */ + val restoresOnForeground: Boolean = true, ) internal class ClearedMediaRecoveryState { @@ -14,19 +16,21 @@ internal class ClearedMediaRecoveryState { videoId: String?, positionMs: Long, playWhenReady: Boolean, - localFilePath: String? + localFilePath: String?, + restoresOnForeground: Boolean = true, ) { if (videoId == null) return - pendingSnapshot = ClearedMediaRecoverySnapshot( - videoId = videoId, - positionMs = positionMs.coerceAtLeast(0L), - playWhenReady = playWhenReady, - localFilePath = localFilePath - ) + pendingSnapshot = + ClearedMediaRecoverySnapshot( + videoId = videoId, + positionMs = positionMs.coerceAtLeast(0L), + playWhenReady = playWhenReady, + localFilePath = localFilePath, + restoresOnForeground = restoresOnForeground, + ) } - fun pendingFor(videoId: String?): ClearedMediaRecoverySnapshot? = - pendingSnapshot?.takeIf { it.videoId == videoId } + fun pendingFor(videoId: String?): ClearedMediaRecoverySnapshot? = pendingSnapshot?.takeIf { it.videoId == videoId } fun complete(snapshot: ClearedMediaRecoverySnapshot) { if (pendingSnapshot === snapshot) { @@ -34,6 +38,21 @@ internal class ClearedMediaRecoveryState { } } + /** + * Where a reload should start: the saved position while the player is still empty for [videoId], + * consuming it, otherwise [currentPositionMs]. An emptied player reports 0. + */ + fun takeReloadPosition( + videoId: String?, + playerEmpty: Boolean, + currentPositionMs: Long, + ): Long { + val snapshot = if (playerEmpty) pendingFor(videoId) else null + snapshot ?: return currentPositionMs + complete(snapshot) + return snapshot.positionMs + } + fun clear() { pendingSnapshot = null } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerControlsOverlay.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerControlsOverlay.kt index 4f4ba867fd..9ce691bf50 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerControlsOverlay.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerControlsOverlay.kt @@ -379,26 +379,19 @@ internal fun PlayerControlsOverlay( } } - AnimatedVisibility( + PlayerIdleProgressStrip( + position = overlayPreferences.idleProgressBar, visible = !isVisible && !isFullscreen && !isInitialLoading && !state.isTouchLocked, - enter = fadeIn(fadeSpec), - exit = fadeOut(fadeSpec), - modifier = Modifier.align(Alignment.BottomCenter), - ) { - Box(modifier = Modifier.fillMaxWidth()) { - PlayerSeekbarRow( - positionProvider = displayedPosition, - duration = state.duration, - isLive = state.isLive, - content = seekbarContent, - edgeAligned = true, - horizontalPadding = seekbarHorizontalPadding, - onScrubProgress = onScrubProgress, - onScrubFinished = onScrubFinished, - isScrubbing = scrubController.isScrubbing, - ) - } - } + fadeSpec = fadeSpec, + positionProvider = displayedPosition, + duration = state.duration, + isLive = state.isLive, + content = seekbarContent, + horizontalPadding = seekbarHorizontalPadding, + onScrubProgress = onScrubProgress, + onScrubFinished = onScrubFinished, + isScrubbing = scrubController.isScrubbing, + ) } } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerIdleProgressStrip.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerIdleProgressStrip.kt new file mode 100644 index 0000000000..186a01cc8c --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerIdleProgressStrip.kt @@ -0,0 +1,74 @@ +package io.github.aedev.flow.ui.components.videoplayer.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import io.github.aedev.flow.data.local.IdleProgressBar +import io.github.aedev.flow.ui.components.shared.RestTrackHeight + +/** + * The thin progress bar the portrait player keeps while its controls are hidden (#1550). + * + * On the video it is the edge-aligned bar and takes touches. Below the video it hangs under the + * box's bottom edge, outside the bounds any touch can reach, so it is drawn only: scrubbing waits + * for the full bar that comes with the controls. + */ +@Composable +internal fun BoxScope.PlayerIdleProgressStrip( + position: IdleProgressBar, + visible: Boolean, + fadeSpec: FiniteAnimationSpec, + positionProvider: () -> Long, + duration: Long, + isLive: Boolean, + content: PlayerSeekbarContent, + horizontalPadding: Dp, + onScrubProgress: (progress: Float, duration: Long) -> Unit, + onScrubFinished: () -> Unit, + isScrubbing: Boolean, +) { + if (position == IdleProgressBar.OFF) return + val below = position == IdleProgressBar.BELOW_VIDEO + AnimatedVisibility( + visible = visible, + enter = fadeIn(fadeSpec), + exit = fadeOut(fadeSpec), + modifier = + Modifier + .align(Alignment.BottomCenter) + .then(if (below) Modifier.hangBelowEdge() else Modifier), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + PlayerSeekbarRow( + positionProvider = positionProvider, + duration = duration, + isLive = isLive, + content = content, + edgeAligned = true, + horizontalPadding = horizontalPadding, + onScrubProgress = onScrubProgress, + onScrubFinished = onScrubFinished, + isScrubbing = isScrubbing, + interactive = !below, + ) + } + } +} + +/** Moves the painted band, which sits at the bottom of the row, to start just under the box edge. */ +private fun Modifier.hangBelowEdge(): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, minOf(placeable.height, RestTrackHeight.roundToPx())) + } + } diff --git a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerSeekbarRow.kt b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerSeekbarRow.kt index 7a80960197..549931bcc4 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerSeekbarRow.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/components/videoplayer/controls/PlayerSeekbarRow.kt @@ -75,6 +75,7 @@ internal fun PlayerSeekbarRow( modifier: Modifier = Modifier, seekbarZIndex: Float = 0f, isScrubbing: Boolean = false, + interactive: Boolean = true, ) { if (isLive && duration <= 0L) { Box( @@ -127,6 +128,7 @@ internal fun PlayerSeekbarRow( onScrubProgress = onScrubProgress, onScrubFinished = onScrubFinished, onTrackWidth = { trackWidthPx = it }, + interactive = interactive, ) } } @@ -234,6 +236,7 @@ private fun SeekBar( onScrubProgress: (progress: Float, duration: Long) -> Unit, onScrubFinished: () -> Unit, onTrackWidth: (Int) -> Unit, + interactive: Boolean, ) { MediaSeekBar( value = { @@ -245,6 +248,7 @@ private fun SeekBar( }, onValueChange = { progress -> onScrubProgress(progress, seekDuration) }, onValueChangeFinished = onScrubFinished, + enabled = interactive, chapters = content.chapters, sponsorSegments = content.sponsorSegments, sponsorColors = content.sponsorColors, 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 f218e9240f..1ffb7bb499 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 @@ -40,6 +40,13 @@ internal class PlaybackRecoveryController( fun onPlaybackRequested() = playerManager.resetStreamRecovery() + /** Reloads streams the player still holds at the saved place, as after a decoder failure. */ + fun retryInPlace(): Boolean { + if (!playerManager.retryClearedMedia()) return false + uiState.update { it.copy(error = null, errorHint = null, errorOffersSessionReset = false) } + return true + } + fun onLoadStarted(videoId: String) = playerManager.onStreamLoadStarted(videoId) /** The player itself gave up, so it needs no stopping — only the screen. */ diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerViewModel.kt index e87f760383..9bb4116f50 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/player/VideoPlayerViewModel.kt @@ -403,10 +403,10 @@ class VideoPlayerViewModel fun resetSessionAndRetry() = viewModelScope.launch { resetYouTubeSession() - retryLoadVideo() + retryLoadVideo(reuseLoadedStreams = false) } - fun retryLoadVideo() { + fun retryLoadVideo(reuseLoadedStreams: Boolean = true) { val videoId = _uiState.value.cachedVideo?.id ?: return Log.d("VideoPlayerViewModel", "Retrying video load for $videoId") if (upcomingPremiere.applyCountdown(_uiState.value.cachedVideo ?: return)) { @@ -418,6 +418,7 @@ class VideoPlayerViewModel return } recovery.onPlaybackRequested() + if (reuseLoadedStreams && recovery.retryInPlace()) return playerManager.clearCurrentVideo() _uiState.update { it.copy(error = null, errorHint = null, errorOffersSessionReset = false, isLoading = true) } loadVideoInfo(videoId, isWifi = detectIsWifi(), forceRefresh = true) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceScreen.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceScreen.kt index f30fc57bac..392be4bbe1 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceScreen.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceScreen.kt @@ -13,6 +13,7 @@ import io.github.aedev.flow.R import io.github.aedev.flow.data.local.DEFAULT_FULLSCREEN_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.GestureOverlayStyle +import io.github.aedev.flow.data.local.IdleProgressBar import io.github.aedev.flow.data.local.MAX_FULLSCREEN_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.MAX_PORTRAIT_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.MiniBarSwipeAction @@ -61,6 +62,7 @@ internal fun PlayerAppearanceScreen( val portraitCustom by viewModel.portraitSeekbarCustom.collectAsStateWithLifecycle() val fullscreenMode by viewModel.fullscreenSeekbarMode.collectAsStateWithLifecycle() val fullscreenCustom by viewModel.fullscreenSeekbarCustom.collectAsStateWithLifecycle() + val idleProgressBar by viewModel.idleProgressBar.collectAsStateWithLifecycle() val scrubPreview by viewModel.scrubPreview.collectAsStateWithLifecycle() val frameStep by viewModel.frameStepButtons.collectAsStateWithLifecycle() val musicBackground by viewModel.musicBackground.collectAsStateWithLifecycle() @@ -100,6 +102,12 @@ internal fun PlayerAppearanceScreen( ) val portraitOptions = PortraitSeekbarModes.map { FlowToggleOption(it, stringResource(seekbarModeLabel(it))) } val fullscreenOptions = FullscreenSeekbarModes.map { FlowToggleOption(it, stringResource(seekbarModeLabel(it))) } + val idleProgressOptions = + listOf( + FlowToggleOption(IdleProgressBar.ON_VIDEO, stringResource(R.string.player_appearance_idle_progress_on_video)), + FlowToggleOption(IdleProgressBar.BELOW_VIDEO, stringResource(R.string.player_appearance_idle_progress_below)), + FlowToggleOption(IdleProgressBar.OFF, stringResource(R.string.player_appearance_idle_progress_off)), + ) val scrubOptions = listOf( FlowToggleOption(ScrubPreviewStyle.STRIP, stringResource(R.string.player_appearance_scrub_preview_strip)), @@ -164,6 +172,7 @@ internal fun PlayerAppearanceScreen( viewModel::setFullscreenSeekbarCustom, ) } + toggleGroup(PlayerAppearanceIndex.idleProgressBar, idleProgressOptions, idleProgressBar, viewModel::setIdleProgressBar) toggleGroup(PlayerAppearanceIndex.scrubPreview, scrubOptions, scrubPreview, viewModel::setScrubPreview) switch(PlayerAppearanceIndex.frameStep, frameStep, viewModel::setFrameStepButtons) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt index ca781d451c..96295882aa 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/appearance/player/PlayerAppearanceViewModel.kt @@ -4,6 +4,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.github.aedev.flow.data.local.DEFAULT_FULLSCREEN_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP import io.github.aedev.flow.data.local.GestureOverlayStyle +import io.github.aedev.flow.data.local.IdleProgressBar import io.github.aedev.flow.data.local.MiniBarSwipeAction import io.github.aedev.flow.data.local.MusicPlainControlColors import io.github.aedev.flow.data.local.MusicPlayerBackgroundStyle @@ -30,6 +31,7 @@ class PlayerAppearanceViewModel val portraitSeekbarCustom = preferences.portraitSeekbarCustomPaddingDp.asState(DEFAULT_PORTRAIT_SEEKBAR_PADDING_DP) val fullscreenSeekbarMode = preferences.fullscreenSeekbarPaddingMode.asState(SeekbarPaddingMode.DEFAULT) val fullscreenSeekbarCustom = preferences.fullscreenSeekbarCustomPaddingDp.asState(DEFAULT_FULLSCREEN_SEEKBAR_PADDING_DP) + val idleProgressBar = preferences.idleProgressBar.asState(overlayDefaults.idleProgressBar) val scrubPreview = preferences.scrubPreviewStyle.asState(overlayDefaults.scrubPreviewStyle) val frameStepButtons = preferences.frameStepButtonsEnabled.asState(overlayDefaults.frameStepButtonsEnabled) val musicBackground = preferences.musicPlayerBackgroundStyle.asState(MusicPlayerBackgroundStyle.BLUR_GRADIENT) @@ -63,6 +65,8 @@ class PlayerAppearanceViewModel fun setFullscreenSeekbarCustom(value: Int) = write { preferences.setFullscreenSeekbarCustomPaddingDp(value) } + fun setIdleProgressBar(value: IdleProgressBar) = write { preferences.setIdleProgressBar(value) } + fun setScrubPreview(value: ScrubPreviewStyle) = write { preferences.setScrubPreviewStyle(value) } fun setFrameStepButtons(value: Boolean) = write { preferences.setFrameStepButtonsEnabled(value) } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt index b181c4ec76..804c1ff6ab 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/index/PlayerAppearanceIndex.kt @@ -46,6 +46,13 @@ internal object PlayerAppearanceIndex { section = R.string.settings_section_seek_bar, revealVia = "player_appearance.fullscreen_width", ) + val idleProgressBar = + entry( + "idle_progress_bar", + R.string.player_appearance_idle_progress_title, + R.string.player_appearance_idle_progress_subtitle, + R.string.settings_section_seek_bar, + ) val scrubPreview = entry( "scrub_preview", @@ -180,6 +187,7 @@ internal object PlayerAppearanceIndex { portraitCustom, fullscreenWidth, fullscreenCustom, + idleProgressBar, scrubPreview, frameStep, musicBackground, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 581b27eaa0..2007dab9b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -979,6 +979,11 @@ Default Custom %1$d dp + Progress Bar With Hidden Controls + Where the thin bar sits in portrait + On video + Below + Off Scrub Preview What the seekbar shows while you drag it Filmstrip diff --git a/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt b/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt index d3b09a1d30..eaad1c28a2 100644 --- a/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/local/DevicePreferencesSplitTest.kt @@ -24,7 +24,7 @@ class DevicePreferencesSplitTest { val profile = keyNames("Keys") val device = keyNames("DeviceKeys") - assertThat(device).hasSize(101) + assertThat(device).hasSize(102) assertThat(profile).hasSize(202) assertThat(device.toSet()).hasSize(device.size) assertThat(profile.toSet()).hasSize(profile.size) diff --git a/app/src/test/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClientTest.kt b/app/src/test/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClientTest.kt index a4f82599e4..dc19947d65 100644 --- a/app/src/test/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClientTest.kt +++ b/app/src/test/java/io/github/aedev/flow/data/scrobble/AudioscrobblerClientTest.kt @@ -14,25 +14,27 @@ class AudioscrobblerClientTest { private val keys = AudioscrobblerKeys("key", "secret") private val sent = mutableListOf>() - private fun client(reply: String) = - AudioscrobblerClient("https://example.invalid/2.0/") { - OkHttpClient - .Builder() - .addInterceptor { chain -> - val form = chain.request().body as? FormBody - sent += - form?.let { (0 until it.size).associate { i -> it.name(i) to it.value(i) } } - ?: chain.request().url.let { url -> url.queryParameterNames.associateWith { url.queryParameter(it).orEmpty() } } - Response - .Builder() - .request(chain.request()) - .protocol(Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(reply.toResponseBody("application/json".toMediaType())) - .build() - }.build() - } + private fun client( + reply: String, + sessionAuth: MobileSessionAuth = MobileSessionAuth.PASSWORD, + ) = AudioscrobblerClient("https://example.invalid/2.0/", sessionAuth = sessionAuth, http = { + OkHttpClient + .Builder() + .addInterceptor { chain -> + val form = chain.request().body as? FormBody + sent += + form?.let { (0 until it.size).associate { i -> it.name(i) to it.value(i) } } + ?: chain.request().url.let { url -> url.queryParameterNames.associateWith { url.queryParameter(it).orEmpty() } } + Response + .Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(reply.toResponseBody("application/json".toMediaType())) + .build() + }.build() + }) @Test fun `the signature follows the documented recipe`() { @@ -56,6 +58,37 @@ class AudioscrobblerClientTest { assertThat(form["api_sig"]).isEqualTo(audioscrobblerSignature(form - "api_sig" - "format", "secret")) } + @Test + fun `the auth token is the md5 of the lower-cased name and the password's md5`() { + assertThat(audioscrobblerAuthToken("listener", "pw")).isEqualTo("1d12e5ee336fb5afd24c404f2f7a6c1a") + assertThat(audioscrobblerAuthToken("Listener", "pw")).isEqualTo(audioscrobblerAuthToken("listener", "pw")) + } + + @Test + fun `a token sign in sends the auth token and never the password`() = + runBlocking { + client( + """{"session":{"name":"Listener","key":"SK","subscriber":0}}""", + sessionAuth = MobileSessionAuth.AUTH_TOKEN, + ).signIn("Listener", "pw", keys).getOrThrow() + + val form = sent.single() + assertThat(form).doesNotContainKey("password") + assertThat(form["authToken"]).isEqualTo("1d12e5ee336fb5afd24c404f2f7a6c1a") + assertThat(form["username"]).isEqualTo("Listener") + assertThat(form["api_sig"]).isEqualTo(audioscrobblerSignature(form - "api_sig" - "format", "secret")) + } + + @Test + fun `a password sign in sends the password and no token`() = + runBlocking { + client("""{"session":{"name":"Listener","key":"SK","subscriber":0}}""").signIn("listener", "pw", keys).getOrThrow() + + val form = sent.single() + assertThat(form["password"]).isEqualTo("pw") + assertThat(form).doesNotContainKey("authToken") + } + @Test fun `a rejected sign in carries the service's message`() = runBlocking { diff --git a/app/src/test/java/io/github/aedev/flow/player/error/PlayerErrorHandlerDecoderTest.kt b/app/src/test/java/io/github/aedev/flow/player/error/PlayerErrorHandlerDecoderTest.kt new file mode 100644 index 0000000000..070f59ea32 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/error/PlayerErrorHandlerDecoderTest.kt @@ -0,0 +1,62 @@ +package io.github.aedev.flow.player.error + +import android.content.Context +import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.player.state.EnhancedPlayerState +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test + +@UnstableApi +class PlayerErrorHandlerDecoderTest { + private val events = mutableListOf() + private val player = mockk(relaxed = true) + + private val handler = + PlayerErrorHandler( + appContext = mockk(relaxed = true), + stateFlow = MutableStateFlow(EnhancedPlayerState()), + onReloadStream = { _, _ -> }, + onQualityDowngrade = {}, + onPlaybackShutdown = { events += "shutdown" }, + onStreamExpired = {}, + onPlaybackAbandoned = {}, + onDecoderGaveUp = { events += "gave up" }, + rememberPlaybackForRecovery = { events += "remember" }, + onGatedCodecFallback = { false }, + getFailedStreamUrls = { emptySet() }, + markStreamFailed = {}, + incrementStreamErrors = {}, + getStreamErrorCount = { 0 }, + isAdaptiveQualityEnabled = { true }, + getManualQualityHeight = { null }, + getCurrentVideoStream = { null }, + getCurrentAudioStream = { null }, + getAvailableAudioStreams = { emptyList() }, + setCurrentAudioStream = {}, + setRecoveryState = {}, + reloadPlaybackManager = {}, + ) + + private fun decoderFailure() = PlaybackException("codec died", null, PlaybackException.ERROR_CODE_DECODING_FAILED) + + @Test + fun `the first decoder failure re-prepares in place and keeps the media`() { + assertThat(handler.handleError(decoderFailure(), player)).isTrue() + + verify(exactly = 1) { player.prepare() } + assertThat(events).isEmpty() + } + + @Test + fun `giving up remembers the place before the player is emptied`() { + handler.handleError(decoderFailure(), player) + handler.handleError(decoderFailure(), player) + + assertThat(events).containsExactly("remember", "shutdown", "gave up").inOrder() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/quality/AdaptiveTrackSelectorDefaultsTest.kt b/app/src/test/java/io/github/aedev/flow/player/quality/AdaptiveTrackSelectorDefaultsTest.kt index dddaa4e111..9ed24b7202 100644 --- a/app/src/test/java/io/github/aedev/flow/player/quality/AdaptiveTrackSelectorDefaultsTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/quality/AdaptiveTrackSelectorDefaultsTest.kt @@ -17,7 +17,7 @@ class AdaptiveTrackSelectorDefaultsTest { .setForceHighestSupportedBitrate(true) @Test - fun `defaults keep the heap cap`() { + fun `defaults keep the low-RAM cap`() { val params = adaptiveTrackSelectorDefaults(manualLivePick(), VideoSizeCap(1920, 1080)).build() assertThat(params.maxVideoWidth).isEqualTo(1920) @@ -25,7 +25,7 @@ class AdaptiveTrackSelectorDefaultsTest { } @Test - fun `defaults keep the 4K cap on a large heap`() { + fun `defaults keep the 4K cap on other devices`() { val params = adaptiveTrackSelectorDefaults(manualLivePick(), VideoSizeCap.UHD).build() assertThat(params.maxVideoWidth).isEqualTo(3840) @@ -50,10 +50,8 @@ class AdaptiveTrackSelectorDefaultsTest { } @Test - fun `the heap cap tightens on smaller heaps`() { - assertThat(VideoSizeCap.forHeap(memoryClassMb = 192, isLowRamDevice = false)).isEqualTo(VideoSizeCap(1920, 1080)) - assertThat(VideoSizeCap.forHeap(memoryClassMb = 512, isLowRamDevice = true)).isEqualTo(VideoSizeCap(1920, 1080)) - assertThat(VideoSizeCap.forHeap(memoryClassMb = 384, isLowRamDevice = false)).isEqualTo(VideoSizeCap(2560, 1440)) - assertThat(VideoSizeCap.forHeap(memoryClassMb = 512, isLowRamDevice = false)).isEqualTo(VideoSizeCap.UHD) + fun `only a low-RAM device is held at 1080p`() { + assertThat(VideoSizeCap.forRam(isLowRamDevice = true)).isEqualTo(VideoSizeCap(1920, 1080)) + assertThat(VideoSizeCap.forRam(isLowRamDevice = false)).isEqualTo(VideoSizeCap.UHD) } } diff --git a/app/src/test/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryStateTest.kt b/app/src/test/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryStateTest.kt index f48b452a18..f603ea0b03 100644 --- a/app/src/test/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryStateTest.kt +++ b/app/src/test/java/io/github/aedev/flow/player/recovery/ClearedMediaRecoveryStateTest.kt @@ -12,7 +12,7 @@ class ClearedMediaRecoveryStateTest { videoId = "video-id", positionMs = 12_345L, playWhenReady = false, - localFilePath = "/media/video.mp4" + localFilePath = "/media/video.mp4", ) assertThat(state.pendingFor("video-id")).isEqualTo( @@ -20,8 +20,8 @@ class ClearedMediaRecoveryStateTest { videoId = "video-id", positionMs = 12_345L, playWhenReady = false, - localFilePath = "/media/video.mp4" - ) + localFilePath = "/media/video.mp4", + ), ) } @@ -64,4 +64,34 @@ class ClearedMediaRecoveryStateTest { assertThat(state.pendingFor("video-id")).isNull() } + + @Test + fun `a reload of an emptied player takes the saved position once`() { + val state = ClearedMediaRecoveryState() + state.capture(videoId = "v", positionMs = 90_000L, playWhenReady = false, localFilePath = null) + + assertThat(state.takeReloadPosition("v", playerEmpty = true, currentPositionMs = 0L)).isEqualTo(90_000L) + assertThat(state.pendingFor("v")).isNull() + assertThat(state.takeReloadPosition("v", playerEmpty = true, currentPositionMs = 0L)).isEqualTo(0L) + } + + @Test + fun `a player that still holds media reloads at its own position`() { + val state = ClearedMediaRecoveryState() + state.capture(videoId = "v", positionMs = 90_000L, playWhenReady = false, localFilePath = null) + + assertThat(state.takeReloadPosition("v", playerEmpty = false, currentPositionMs = 42_000L)).isEqualTo(42_000L) + assertThat(state.takeReloadPosition("other", playerEmpty = true, currentPositionMs = 0L)).isEqualTo(0L) + assertThat(state.pendingFor("v")).isNotNull() + } + + @Test + fun `a snapshot restores on foreground unless asked not to`() { + val state = ClearedMediaRecoveryState() + state.capture(videoId = "v", positionMs = 1L, playWhenReady = false, localFilePath = null) + assertThat(state.pendingFor("v")?.restoresOnForeground).isTrue() + + state.capture(videoId = "v", positionMs = 1L, playWhenReady = false, localFilePath = null, restoresOnForeground = false) + assertThat(state.pendingFor("v")?.restoresOnForeground).isFalse() + } }