From cfd654b2674305fc432d407d378a49397167f467 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 05:17:03 +0200 Subject: [PATCH] feat(android,player): the read-ahead on the seek bar pstr_mpv observes demuxer-cache-time and hands it over with the rest of the state, and the seek bar shades from the start to where the demuxer has read, lighter than the played part. A jump that lands in the shaded run costs no fetch, as on the desktop. --- android/app/src/main/cpp/pstr_mpv.cpp | 9 +++++++-- .../io/narl/protonstream/playback/NativeMpvHost.kt | 6 +++++- .../io/narl/protonstream/playback/PlayerScreen.kt | 7 ++++++- .../java/io/narl/protonstream/ui/theme/Accent.kt | 12 +++++++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/cpp/pstr_mpv.cpp b/android/app/src/main/cpp/pstr_mpv.cpp index bc5c494..aaf279e 100644 --- a/android/app/src/main/cpp/pstr_mpv.cpp +++ b/android/app/src/main/cpp/pstr_mpv.cpp @@ -193,6 +193,9 @@ struct PlaybackState { /// Between a seek being issued and playback actually resuming. The seek bar /// has moved but the picture has not. bool seeking = false; + /// How far into the file the demuxer has read ahead, in seconds; what the + /// seek bar shades, so a jump into it is known to cost no fetch. + double cached_until = 0.0; /// True between `loadfile` being issued and mpv acknowledging it with /// `START_FILE`. Everything mpv reports in that window still describes the /// *outgoing* file — including the `END_FILE(STOP)` that stopping it emits — @@ -248,6 +251,7 @@ class Player { observe("video-params/dh", 7, MPV_FORMAT_INT64); observe("paused-for-cache", 8, MPV_FORMAT_FLAG); observe("cache-buffering-state", 9, MPV_FORMAT_INT64); + observe("demuxer-cache-time", 10, MPV_FORMAT_DOUBLE); // Errors mpv reports about the file itself, rather than about the // request that opened it. Without these a failed demux is silent. mpv_request_log_messages(mpv_, "error"); @@ -598,6 +602,7 @@ class Player { else if (!std::strcmp(property.name, "video-params/dh")) state_.video_height = static_cast(*static_cast(property.data)); else if (!std::strcmp(property.name, "paused-for-cache")) state_.buffering = *static_cast(property.data); else if (!std::strcmp(property.name, "cache-buffering-state")) state_.cache_percent = static_cast(*static_cast(property.data)); + else if (!std::strcmp(property.name, "demuxer-cache-time")) state_.cached_until = *static_cast(property.data); } static void render_update(void *opaque) noexcept { @@ -876,8 +881,8 @@ extern "C" JNIEXPORT jdoubleArray JNICALL Java_io_narl_protonstream_playback_Nat state.video_width, state.video_height, static_cast(state.end_reason), state.buffering ? 1.0 : 0.0, state.cache_percent, - state.seeking ? 1.0 : 0.0}; - constexpr jsize kFields = 13; + state.seeking ? 1.0 : 0.0, state.cached_until}; + constexpr jsize kFields = 14; jdoubleArray result = env->NewDoubleArray(kFields); if (!result) return static_cast(nullptr); env->SetDoubleArrayRegion(result, 0, kFields, values); diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt index 2c09115..bd2a0e6 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt @@ -75,6 +75,8 @@ data class MpvPlaybackState( val cachePercent: Double = 0.0, /** A seek has been issued but playback has not resumed at the new position. */ val seeking: Boolean = false, + /** How far the demuxer has read ahead, in seconds into the file. */ + val cachedUntil: Double = 0.0, ) { /** Whether the picture is stopped for a reason the viewer did not choose. */ val stalled: Boolean get() = (buffering || seeking) && !paused && !ended @@ -313,6 +315,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv buffering = values[BUFFERING] != 0.0, cachePercent = values[CACHE_PERCENT], seeking = values[SEEKING] != 0.0, + cachedUntil = values[CACHED_UNTIL], ) mutableState.value = next // The listeners drive the media session and the player UI, @@ -431,7 +434,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv companion object { /** Fields `nativeState` fills, in the order `pstr_mpv.cpp` writes them. */ - private const val FIELDS = 13 + private const val FIELDS = 14 private const val GENERATION = 6 private const val WIDTH = 7 private const val HEIGHT = 8 @@ -439,6 +442,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv private const val BUFFERING = 10 private const val CACHE_PERCENT = 11 private const val SEEKING = 12 + private const val CACHED_UNTIL = 13 init { System.loadLibrary("pstr_android") diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 2df88a3..ebd81e9 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -640,7 +640,12 @@ private fun PlayerControls( scrubbing = null }, valueRange = 0f..duration, - track = { AccentTrack(it) }, + track = { + AccentTrack( + it, + buffered = if (state.duration > 0) (state.cachedUntil / state.duration).toFloat() else 0f, + ) + }, ) ChapterMarks(chapters, state.duration, Modifier.matchParentSize()) } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt index 1fb9bfb..2328b14 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt @@ -168,11 +168,21 @@ internal fun AccentProgress( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier) { +internal fun AccentTrack(state: SliderState, modifier: Modifier = Modifier, buffered: Float = 0f) { val brush = accentBrush() Canvas(modifier.fillMaxWidth().height(TRACK_HEIGHT.dp)) { val radius = CornerRadius(size.height / 2) drawRoundRect(color = Color.White.copy(alpha = 0.30f), cornerRadius = radius) + // What is already read ahead, lighter than the rest and under the + // played part: a jump that lands in it costs no fetch. + val ahead = size.width * buffered.coerceIn(0f, 1f) + if (ahead >= size.height) { + drawRoundRect( + color = Color.White.copy(alpha = 0.55f), + size = Size(ahead, size.height), + cornerRadius = radius, + ) + } val done = size.width * state.coercedValueAsFraction if (done >= size.height) { drawRoundRect(brush = brush, size = Size(done, size.height), cornerRadius = radius)