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)?;