feat(app,stream): storage and hardware-decoding settings

- the streaming cache's budget (1-16 GiB) is chosen in Settings, shows
  what it holds, and can be emptied; a lower budget evicts at once
- hardware decoding can be turned off for a driver that shows green
  frames, from the next file
- both live in desktop.json rather than the playback prefs shared with
  Android, whose bridge record would reset them on every save. A file
  that will not parse is never overwritten
This commit is contained in:
nvrl committed 2026-10-02 20:16:27 +02:00
1 parent 935a985589
commit a432a93cf9
9 files changed
+315 -5

No files matched your search

+8
View File
@@ -163,6 +163,8 @@ pub enum Action {
SetSpeed(f64),
/// Seek past openings and credits without being asked.
SetAutoSkip(bool),
SetDesktopPrefs(crate::desktop_prefs::DesktopPrefs),
ClearStreamCache,
/// Repaint the window in a different palette.
SetAppearance(Appearance),
/// In or out of fullscreen, from the player page.
@@ -1240,6 +1242,8 @@ impl App {
self.note(ctx, text, false);
}
}
Action::SetDesktopPrefs(prefs) => self.engine.set_desktop_prefs(prefs),
Action::ClearStreamCache => self.engine.clear_stream_cache(),
Action::SetAutoSkip(auto_skip) => {
let mut prefs = self.engine.playback_prefs();
prefs.auto_skip = auto_skip;
@@ -1509,6 +1513,10 @@ impl eframe::App for App {
autoplay: playback_prefs.autoplay_next,
auto_skip: playback_prefs.auto_skip,
appearance: self.engine.appearance(),
desktop: self.engine.desktop_prefs(),
cache_used: (self.page == Page::Settings)
.then(|| self.engine.cache_used())
.flatten(),
};
const NAV_MARGIN: egui::Vec2 = egui::vec2(14.0, 10.0);
+106
View File
@@ -0,0 +1,106 @@
//! Settings only the desktop app has.
//!
//! Kept out of [`pstr_core::prefs::PlaybackPrefs`] on purpose: that one is
//! shared with Android through a bridge record, and a field Android neither
//! shows nor keeps would be reset to its default every time Android saved.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// What the settings page offers for the streaming cache, in GiB.
pub const CACHE_CHOICES: [u32; 5] = [1, 2, 4, 8, 16];
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
// A file from an older version, or edited by hand, missing a field: the rest
// still counts, and the missing one takes its default.
#[serde(default)]
pub struct DesktopPrefs {
/// Let mpv decode on the GPU. Off is for a driver that shows green frames
/// or nothing; it costs CPU, not correctness.
pub hardware_decoding: bool,
/// How much decrypted video the disk cache may keep.
pub cache_gib: u32,
}
impl Default for DesktopPrefs {
fn default() -> Self {
Self {
hardware_decoding: true,
cache_gib: 4,
}
}
}
impl DesktopPrefs {
pub fn cache_bytes(&self) -> u64 {
u64::from(self.cache_gib.clamp(1, 64)) << 30
}
}
/// The prefs and where they live.
pub struct DesktopPrefsFile {
path: PathBuf,
/// False when the file was there and would not parse. Such a file is never
/// overwritten — it is far more likely a bug than something to replace —
/// so changes then last only for the session.
writable: bool,
}
impl DesktopPrefsFile {
pub fn load(path: PathBuf) -> (Self, DesktopPrefs) {
match pstr_core::config::read_json::<DesktopPrefs>(&path) {
Ok(prefs) => (
Self {
path,
writable: true,
},
prefs.unwrap_or_default(),
),
Err(error) => {
tracing::warn!("read the desktop settings: {error}");
(
Self {
path,
writable: false,
},
DesktopPrefs::default(),
)
}
}
}
pub fn save(&self, prefs: &DesktopPrefs) -> pstr_core::Result<()> {
if !self.writable {
tracing::warn!(
"not saving the desktop settings over {}, which would not parse",
self.path.display()
);
return Ok(());
}
pstr_core::config::write_json(&self.path, prefs)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_file_missing_a_field_keeps_the_rest() {
let prefs: DesktopPrefs = serde_json::from_str(r#"{ "cache_gib": 8 }"#).unwrap();
assert_eq!(prefs.cache_gib, 8);
assert!(prefs.hardware_decoding);
}
#[test]
fn an_unparseable_file_is_never_overwritten() {
let path = std::env::temp_dir().join(format!("pstr-desktop-{}.json", std::process::id()));
std::fs::write(&path, "{ not json").unwrap();
let (file, prefs) = DesktopPrefsFile::load(path.clone());
assert_eq!(prefs, DesktopPrefs::default());
file.save(&DesktopPrefs::default()).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
let _ = std::fs::remove_file(path);
}
}
+60 -1
View File
@@ -37,6 +37,8 @@ use pstr_core::proton_drive_rs::ThumbnailType;
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::{Share, ShareStore, SharedLibrary};
use pstr_meta::MetadataService;
use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile};
use pstr_stream::{
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource,
VideoStream,
@@ -318,6 +320,9 @@ pub struct Engine {
download_permits: Arc<tokio::sync::Semaphore>,
/// Whether the player page is on screen. See [`Engine::wake`].
picture_shown: Arc<AtomicBool>,
/// Settings only the desktop has, and the file they are kept in.
desktop: Arc<Mutex<DesktopPrefs>>,
desktop_file: Arc<DesktopPrefsFile>,
/// Bumped to stop the crawls in flight; each watches it from its start.
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
events: Sender<Event>,
@@ -371,6 +376,7 @@ impl Engine {
) -> anyhow::Result<(Self, Receiver<Event>)> {
let catalog = Catalog::open(&dirs.catalog_db())?;
let (events, receiver) = channel();
let (desktop_file, desktop) = DesktopPrefsFile::load(dirs.desktop_prefs_file());
// An unreadable settings file must not stop the app starting: the
// library is the point and enrichment is decoration. It is reported and
@@ -417,6 +423,8 @@ impl Engine {
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
picture_shown: Arc::new(AtomicBool::new(false)),
crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0),
desktop: Arc::new(Mutex::new(desktop)),
desktop_file: Arc::new(desktop_file),
events,
ctx,
};
@@ -667,7 +675,10 @@ impl Engine {
// mpv brings its own read-ahead; a second one under it only competes
// for the bandwidth mpv is blocked on. See `pstr_player::READAHEAD_BLOCKS`.
.with_readahead(pstr_player::READAHEAD_BLOCKS)
.with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache()));
.with_disk_cache(
DiskCacheConfig::new(self.dirs.block_cache())
.with_budget(self.desktop_prefs().cache_bytes()),
);
let source = match StreamSource::new(opener, config).await {
Ok(source) => source,
@@ -1683,6 +1694,54 @@ impl Engine {
// ---------------------------------------------------------------- enrichment
impl Engine {
pub fn desktop_prefs(&self) -> DesktopPrefs {
*self.desktop.lock()
}
/// Change the desktop-only settings, write them, and apply what can be
/// applied now. Hardware decoding takes effect from the next file.
pub fn set_desktop_prefs(&self, prefs: DesktopPrefs) {
*self.desktop.lock() = prefs;
let engine = self.clone();
self.runtime.spawn(async move {
let file = Arc::clone(&engine.desktop_file);
let saved = tokio::task::spawn_blocking(move || file.save(&prefs)).await;
if let Ok(Err(error)) = saved {
engine.fail("save the desktop settings", error);
}
if let Some((_, source)) = engine.opened() {
source.set_disk_budget(prefs.cache_bytes()).await;
}
});
}
/// How much the streaming cache holds, once the shares are open.
pub fn cache_used(&self) -> Option<u64> {
let (_, source) = self.opened()?;
source.disk_stats().map(|stats| stats.stored_bytes)
}
/// Empty the streaming cache.
pub fn clear_stream_cache(&self) {
let engine = self.clone();
self.runtime.spawn(async move {
match engine.opened() {
Some((_, source)) => source.clear_disk_cache().await,
// Not open, so nothing holds an index of it to fall out of step
// with: the directory can simply go.
None => {
let root = engine.dirs.block_cache();
if let Err(error) = tokio::fs::remove_dir_all(&root).await
&& error.kind() != std::io::ErrorKind::NotFound
{
return engine.fail("clear the streaming cache", error);
}
}
}
engine.emit(Event::Status("cleared the streaming cache".into()));
});
}
/// The enrichment settings as they stand.
pub fn metadata_config(&self) -> MetadataConfig {
self.metadata.lock().config.clone()
+1
View File
@@ -20,6 +20,7 @@
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
pub mod app;
pub mod desktop_prefs;
pub mod engine;
pub mod inhibit;
pub mod media;
+2
View File
@@ -423,6 +423,7 @@ fn build_player(
// controller would be a second set of controls over the same file.
on_screen_controller: false,
default_keybindings: false,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref())
};
@@ -456,6 +457,7 @@ fn build_player(
let config = PlayerConfig {
window_title: title,
hardware_decoding: engine.desktop_prefs().hardware_decoding,
..from_prefs(prefs, target.track_prefs.as_deref())
};
let player = Player::new(engine.runtime().clone(), config)
+64
View File
@@ -9,6 +9,7 @@ use pstr_core::appearance::{Accent, Appearance, Flavor};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::Action;
use crate::desktop_prefs::{CACHE_CHOICES, DesktopPrefs};
use crate::theme;
use crate::ui;
@@ -22,6 +23,9 @@ pub struct Prefs {
pub autoplay: bool,
pub auto_skip: bool,
pub appearance: Appearance,
pub desktop: DesktopPrefs,
/// What the streaming cache holds, once the shares are open.
pub cache_used: Option<u64>,
}
/// The widest the settings column gets. Rows that run the width of a 2K window
@@ -49,6 +53,7 @@ pub fn show(
ui.add_space(8.0);
appearance_form(ui, prefs.appearance, actions);
playback_form(ui, prefs, actions);
storage_form(ui, prefs, actions);
metadata_form(ui, settings, api_key, actions);
about(ui);
});
@@ -256,6 +261,65 @@ fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
}
},
);
let mut desktop = prefs.desktop;
ui::widgets::settings_row(
ui,
"Hardware decoding",
Some(
"Decode on the graphics card. Turn off if video shows green frames or nothing at all; it costs CPU, not quality. From the next file.",
),
|ui| {
if ui::widgets::toggle(ui, &mut desktop.hardware_decoding, "").changed() {
actions.push(Action::SetDesktopPrefs(desktop));
}
},
);
},
);
}
/// The streaming cache: how big it may get, and a way to empty it.
fn storage_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec<Action>) {
ui::widgets::settings_group(
ui,
"Storage",
Some(
"Video already fetched is kept on disk, so watching it again or seeking back costs nothing. Downloads are separate and never count towards this.",
),
|ui| {
let budget = ui::format_size(prefs.desktop.cache_bytes() as i64);
let used = match prefs.cache_used {
Some(used) => format!("{} of {budget} in use", ui::format_size(used as i64)),
None => format!("Up to {budget}"),
};
ui::widgets::settings_row(ui, "Streaming cache", Some(&used), |ui| {
let choices: Vec<(u32, String)> = CACHE_CHOICES
.iter()
.map(|&gib| (gib, format!("{gib} GiB")))
.collect();
let labels: Vec<(u32, &str)> = choices
.iter()
.map(|(gib, label)| (*gib, label.as_str()))
.collect();
if let Some(cache_gib) =
ui::widgets::segmented(ui, prefs.desktop.cache_gib, &labels)
{
actions.push(Action::SetDesktopPrefs(DesktopPrefs {
cache_gib,
..prefs.desktop
}));
}
});
ui::widgets::settings_row(
ui,
"Clear the streaming cache",
Some("Frees the space now. Whatever plays next is fetched again."),
|ui| {
if ui.button("Clear").clicked() {
actions.push(Action::ClearStreamCache);
}
},
);
},
);
}
+5
View File
@@ -73,6 +73,11 @@ impl AppDirs {
Ok(())
}
/// Settings only the desktop app has: hardware decoding, the cache budget.
pub fn desktop_prefs_file(&self) -> PathBuf {
self.config.join("desktop.json")
}
/// The desktop window's size, for the next launch to open at.
pub fn window_file(&self) -> PathBuf {
self.config.join("window.json")
+54 -4
View File
@@ -70,7 +70,8 @@ pub struct DiskStats {
pub(crate) struct DiskCache {
root: PathBuf,
budget: u64,
/// Atomic so it can be changed under a running cache, from settings.
budget: std::sync::atomic::AtomicU64,
index: Mutex<Index>,
}
@@ -115,7 +116,7 @@ impl DiskCache {
let cache = Self {
root: config.root,
budget: config.budget_bytes,
budget: std::sync::atomic::AtomicU64::new(config.budget_bytes),
index: Mutex::new(Index {
lru,
bytes,
@@ -169,7 +170,7 @@ impl DiskCache {
/// written is a slower app, not a broken one.
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
let len = block.len() as u64;
if len > self.budget {
if len > self.budget() {
return;
}
@@ -190,6 +191,30 @@ impl DiskCache {
self.evict_to_budget().await;
}
fn budget(&self) -> u64 {
self.budget.load(std::sync::atomic::Ordering::Relaxed)
}
/// Change the budget, shrinking the cache to it at once if it is now over.
pub(crate) async fn set_budget(&self, bytes: u64) {
self.budget
.store(bytes, std::sync::atomic::Ordering::Relaxed);
self.evict_to_budget().await;
}
/// Delete every entry. The cache carries on, empty, under the same budget.
pub(crate) async fn clear(&self) {
let doomed: Vec<PathBuf> = {
let mut index = self.lock();
let doomed = std::iter::from_fn(|| index.lru.pop_lru().map(|(path, _)| path)).collect();
index.bytes = 0;
doomed
};
for path in doomed {
self.discard(&path).await;
}
}
pub(crate) fn stats(&self) -> DiskStats {
let index = self.lock();
DiskStats {
@@ -204,7 +229,7 @@ impl DiskCache {
loop {
let doomed = {
let mut index = self.lock();
if index.bytes <= self.budget {
if index.bytes <= self.budget() {
return;
}
match index.lru.pop_lru() {
@@ -511,6 +536,31 @@ mod tests {
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn a_cleared_cache_is_empty_on_disk_and_in_its_index() {
let root = temp_root("clear");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
for index in 0..3 {
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
}
cache.clear().await;
assert_eq!(cache.stats().stored_bytes, 0);
assert!(cache.get(&key("a", "rev1", 0)).await.is_none());
let reopened = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
assert_eq!(reopened.stats().entries, 0);
}
#[tokio::test]
async fn lowering_the_budget_shrinks_a_running_cache() {
let root = temp_root("lower");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
for index in 0..5 {
cache.put(&key("a", "rev1", index), &[7u8; 100]).await;
}
cache.set_budget(250).await;
assert!(cache.stats().stored_bytes <= 250);
}
/// Reopening with a smaller budget must shrink the cache, not sit over it
/// until enough new blocks arrive to trigger eviction.
#[tokio::test]
+15
View File
@@ -200,6 +200,21 @@ impl StreamSource {
pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> {
self.inner.disk.as_ref().map(|disk| disk.stats())
}
/// Change the disk cache's budget, evicting at once if it is now over.
pub async fn set_disk_budget(&self, bytes: u64) {
if let Some(disk) = &self.inner.disk {
disk.set_budget(bytes).await;
}
}
/// Empty the disk cache. Streams already open carry on; what they had
/// cached is fetched again if it is wanted.
pub async fn clear_disk_cache(&self) {
if let Some(disk) = &self.inner.disk {
disk.clear().await;
}
}
}
impl Inner {