mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-07 09:22:10 +01:00
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.
This commit is contained in:
1 parent
8a1807c262
commit
f2e69dc852
8 files changed
+120
-15
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user