From f2e69dc8524f657ac322431531b9c57c5524d289 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 04:12:20 +0200 Subject: [PATCH] feat(android): streaming cache budget and a hardware decoding switch - Settings > Storage chooses the streaming cache's budget, 1 to 16 GiB, and shows what it holds against it. The bridge gains set_stream_cache_budget, which the next connection is built with and an open one evicts down to at once. Android keeps the choice in its own settings, like the desktop keeps it in desktop.json, and tells the engine as it is created. Unset stays at the shared 4 GiB default. - Settings > Playback can turn hardware decoding off for a device whose decoder shows green or torn frames. pstr_mpv sets hwdec before each loadfile, so it applies from the next episode. --- android/app/src/main/cpp/pstr_mpv.cpp | 16 ++++++--- .../narl/protonstream/native/NativeRuntime.kt | 7 +++- .../protonstream/playback/NativeMpvHost.kt | 16 +++++++-- .../protonstream/playback/PlayerScreen.kt | 2 ++ .../protonstream/playback/StreamSession.kt | 1 + .../protonstream/settings/SettingsStore.kt | 33 ++++++++++++++++--- .../io/narl/protonstream/ui/SettingsScreen.kt | 33 ++++++++++++++++++- crates/pstr-android/src/lib.rs | 27 +++++++++++++-- 8 files changed, 120 insertions(+), 15 deletions(-) diff --git a/android/app/src/main/cpp/pstr_mpv.cpp b/android/app/src/main/cpp/pstr_mpv.cpp index c2f8bb2..bc5c494 100644 --- a/android/app/src/main/cpp/pstr_mpv.cpp +++ b/android/app/src/main/cpp/pstr_mpv.cpp @@ -31,6 +31,8 @@ extern "C" { namespace { constexpr const char *kProtocol = "pstr"; +/// mpv's `hwdec` when hardware decoding is on. Why this value: `Player()`. +constexpr const char *kHardwareDecoding = "auto-copy-safe"; struct StreamCookie { uint64_t handle; @@ -216,6 +218,7 @@ class Player { option("vo", "libmpv"); option("force-window", "no"); option("video-timing-offset", "0"); + // The default; `load` sets it per file from the viewer's setting. // `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into // an `ANativeWindow` the decoder is handed at init, and this player has // none to give: the picture goes through `vo_libmpv` and an EGL pbuffer, @@ -224,7 +227,7 @@ class Player { // `hevc_mediacodec: Both surface and native_window are NULL` before // falling back. The copy variants decode in hardware and hand back // ordinary frames, which is what this render path can actually use. - option("hwdec", "auto-copy-safe"); + option("hwdec", kHardwareDecoding); option("cache", "yes"); option("cache-secs", "30"); option("demuxer-readahead-secs", "30"); @@ -294,8 +297,12 @@ class Player { } bool load(uint64_t handle, double start, const std::string &audio, - const std::string &subtitle, bool subtitles) { + const std::string &subtitle, bool subtitles, + bool hardware_decoding) { if (!mpv_) return false; + // Per file, before `loadfile`: a device whose decoder shows green + // frames is fixed from the next episode without restarting the core. + set_string("hwdec", hardware_decoding ? kHardwareDecoding : "no"); // No wait for a render context. It is created with the render thread and // needs no window, and a load that cannot get one plays audio anyway — // waiting for a surface here is what made background playback fail after @@ -822,7 +829,7 @@ extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHo extern "C" JNIEXPORT void JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeDetachSurface(JNIEnv *, jobject, jlong handle) noexcept { c_boundary([=] { if (auto *p = from(handle)) p->detach(); }); } -extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles) noexcept { +extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeLoad(JNIEnv *env, jobject, jlong handle, jlong stream, jdouble start, jstring audio, jstring subtitle, jboolean subtitles, jboolean hardware_decoding) noexcept { return c_boundary(JNI_FALSE, [=] { std::string audio_text; std::string subtitle_text; @@ -832,7 +839,8 @@ extern "C" JNIEXPORT jboolean JNICALL Java_io_narl_protonstream_playback_NativeM auto *p = from(handle); return static_cast( p && p->load(static_cast(stream), start, audio_text, - subtitle_text, subtitles == JNI_TRUE) + subtitle_text, subtitles == JNI_TRUE, + hardware_decoding == JNI_TRUE) ? JNI_TRUE : JNI_FALSE); }); diff --git a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt index c925074..8ee43c0 100644 --- a/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt +++ b/android/app/src/main/java/io/narl/protonstream/native/NativeRuntime.kt @@ -1,6 +1,7 @@ package io.narl.protonstream.native import android.content.Context +import io.narl.protonstream.settings.SettingsStore import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -56,5 +57,9 @@ object NativeRuntime { } private fun createEngine(): AndroidEngine = - AndroidEngine(paths(), KeystoreSecretStore(appContext)) + AndroidEngine(paths(), KeystoreSecretStore(appContext)).apply { + // The budget is Android's setting, so the engine starts on the + // default and is told the chosen one before anything streams. + setStreamCacheBudget(SettingsStore.gibToBytes(SettingsStore(appContext).cacheBudgetGib)) + } } 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 b8ccc74..2c09115 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 @@ -184,12 +184,16 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv audioLanguage: String?, subtitleLanguage: String?, subtitles: Boolean, + hardwareDecoding: Boolean, ) { check(size > 0uL) { "Cannot play an empty stream" } try { withContext(Dispatchers.IO) { val loaded = withOpenHandle { - nativeLoad(it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles).also { loaded -> + nativeLoad( + it, nativeHandle.toLong(), startPosition, audioLanguage, subtitleLanguage, subtitles, + hardwareDecoding, + ).also { loaded -> if (loaded) { // The outgoing stream is released by mpv closing it, // not from here: `loadfile` is asynchronous and its @@ -398,7 +402,15 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv private external fun nativeDestroy(handle: Long) private external fun nativeAttachSurface(handle: Long, surface: Surface) private external fun nativeDetachSurface(handle: Long) - private external fun nativeLoad(handle: Long, stream: Long, start: Double, audio: String?, subtitle: String?, subtitles: Boolean): Boolean + private external fun nativeLoad( + handle: Long, + stream: Long, + start: Double, + audio: String?, + subtitle: String?, + subtitles: Boolean, + hardwareDecoding: Boolean, + ): Boolean private external fun nativePause(handle: Long, paused: Boolean) private external fun nativeSeek(handle: Long, position: Double) private external fun nativeVolume(handle: Long, volume: Double) 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 4fe316d..2df88a3 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 @@ -76,6 +76,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentTrack @@ -207,6 +208,7 @@ fun PlayerScreen( nativeHost.play( key, startup.nativeHandle, startup.size, startup.startPosition, startup.audioLanguage, startup.subtitleLanguage, startup.subtitles, + SettingsStore(context).hardwareDecoding, ) // After the load, because mpv keeps none of the three across one. nativeHost.setVolume(startup.volume) diff --git a/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt index 5156ddc..207a4a1 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/StreamSession.kt @@ -92,6 +92,7 @@ interface LibmpvHost { audioLanguage: String? = null, subtitleLanguage: String? = null, subtitles: Boolean = true, + hardwareDecoding: Boolean = true, ) /** Calls pstr_android_stream_release exactly once for a published handle. */ diff --git a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt index d03aa46..b89c439 100644 --- a/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt +++ b/android/app/src/main/java/io/narl/protonstream/settings/SettingsStore.kt @@ -20,9 +20,34 @@ class SettingsStore(context: Context) { get() = preferences.getBoolean(KEY_BACKGROUND_AUDIO, true) set(value) { preferences.edit().putBoolean(KEY_BACKGROUND_AUDIO, value).apply() } - private companion object { - const val NAME = "proton_stream_settings" - const val KEY_WIFI_ONLY = "wifi_only" - const val KEY_BACKGROUND_AUDIO = "background_audio" + /** + * Whether mpv may decode in hardware. Off is the way out for a device whose + * MediaCodec decoder shows green or torn frames; it costs battery, so it is + * on unless the viewer turns it off. Read at each load, so a change applies + * from the next file. + */ + var hardwareDecoding: Boolean + get() = preferences.getBoolean(KEY_HARDWARE_DECODING, true) + set(value) { preferences.edit().putBoolean(KEY_HARDWARE_DECODING, value).apply() } + + /** The streaming cache's budget, in GiB. See [CACHE_BUDGET_CHOICES]. */ + var cacheBudgetGib: Int + get() = preferences.getInt(KEY_CACHE_BUDGET_GIB, DEFAULT_CACHE_BUDGET_GIB) + set(value) { preferences.edit().putInt(KEY_CACHE_BUDGET_GIB, value).apply() } + + companion object { + /** The desktop's range, in the steps a phone's storage is bought in. */ + val CACHE_BUDGET_CHOICES = listOf(1, 2, 4, 8, 16) + + /** `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing. */ + const val DEFAULT_CACHE_BUDGET_GIB = 4 + + fun gibToBytes(gib: Int): ULong = gib.toULong() * 1024uL * 1024uL * 1024uL + + private const val NAME = "proton_stream_settings" + private const val KEY_WIFI_ONLY = "wifi_only" + private const val KEY_BACKGROUND_AUDIO = "background_audio" + private const val KEY_HARDWARE_DECODING = "hardware_decoding" + private const val KEY_CACHE_BUDGET_GIB = "cache_budget_gib" } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt index 9feecb7..b4b563c 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -74,6 +74,8 @@ internal fun SettingsScreen( val settings = remember { SettingsStore(context) } var wifiOnly by remember { mutableStateOf(settings.wifiOnly) } var backgroundAudio by remember { mutableStateOf(settings.backgroundAudio) } + var hardwareDecoding by remember { mutableStateOf(settings.hardwareDecoding) } + var cacheBudgetGib by remember { mutableStateOf(settings.cacheBudgetGib) } // Playback preferences are the shared store's, not this app's: they are the // same file the desktop client reads, so a language chosen on one is the // language the other starts in. @@ -114,6 +116,16 @@ internal fun SettingsScreen( ) HorizontalDivider() Text("Playback", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) + SettingToggle("Hardware decoding", hardwareDecoding) { + hardwareDecoding = it + settings.hardwareDecoding = it + } + Text( + "Turn off if video shows green or torn frames. Decoding in software uses more " + + "battery. Applies from the next episode.", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 14.dp), + ) prefs?.let { current -> SettingToggle("Play the next episode automatically", current.autoplayNext) { on -> update { it.copy(autoplayNext = on) } @@ -184,9 +196,28 @@ internal fun SettingsScreen( ) } Text( - "Streaming cache · ${formatBytes(state.storage.cacheBytes)}", + "Streaming cache · ${formatBytes(state.storage.cacheBytes)} of $cacheBudgetGib GiB", style = MaterialTheme.typography.bodySmall, ) + // What has been watched is kept up to this much, so going back over a + // scene costs no download. A lower choice frees the difference at once. + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + items(SettingsStore.CACHE_BUDGET_CHOICES, key = { it }) { gib -> + if (gib == cacheBudgetGib) { + AccentButton(onClick = {}) { Text("$gib GiB") } + } else { + EdgedButton(onClick = { + cacheBudgetGib = gib + settings.cacheBudgetGib = gib + scope.launch(Dispatchers.IO) { + runCatching { + NativeRuntime.engine().setStreamCacheBudget(SettingsStore.gibToBytes(gib)) + } + } + }) { Text("$gib GiB") } + } + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 10.dp)) { // The cache is rebuildable, so it goes without asking. Offline // episodes are a choice the viewer made, so that one asks. diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index fafc6ff..5e5da26 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -717,7 +717,6 @@ struct Connection { generation: u64, library: Arc, open_failures: Vec, - #[allow(dead_code)] source: StreamSource, } @@ -754,6 +753,9 @@ pub struct AndroidEngine { /// between one episode and the next, so age — not just a share-store /// mutation — is a reason to reauthenticate before opening anything. session_refreshed: Mutex>, + /// The streaming cache's budget in bytes, for the next connection and, + /// through [`AndroidEngine::set_stream_cache_budget`], the open one. + cache_budget: AtomicU64, } /// How long a visitor session is assumed good for without a refresh. @@ -798,6 +800,7 @@ impl AndroidEngine { share_generation: AtomicU64::new(0), thumbnails: tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY), session_refreshed: Mutex::new(HashMap::new()), + cache_budget: AtomicU64::new(DiskCacheConfig::DEFAULT_BUDGET_BYTES), })) } @@ -1279,6 +1282,22 @@ impl AndroidEngine { .map_err(BridgeError::from_display)? } + /// Set how much the streaming cache may hold. An open connection's cache + /// evicts down to it at once; a lower budget is a request for the space + /// back, not only a limit on what comes next. + pub fn set_stream_cache_budget(&self, bytes: u64) { + self.cache_budget.store(bytes, Ordering::Release); + let source = self + .connection + .lock() + .as_ref() + .map(|open| open.source.clone()); + if let Some(source) = source { + self.runtime + .spawn(async move { source.set_disk_budget(bytes).await }); + } + } + /// Drop the cached blocks. Rebuildable by definition: this is the one thing /// here that can be reclaimed without losing something the viewer chose. pub fn clear_block_cache(&self) -> Result { @@ -1723,8 +1742,10 @@ impl AndroidEngine { let opener = Arc::new(LibraryOpener::new(Arc::clone(&library))); let source = StreamSource::new( opener, - StreamConfig::default() - .with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())), + StreamConfig::default().with_disk_cache( + DiskCacheConfig::new(self.dirs.block_cache()) + .with_budget(self.cache_budget.load(Ordering::Acquire)), + ), ) .await .map_err(BridgeError::from_display)?;