diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 0c74f28..da95c98 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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); diff --git a/crates/pstr-app/src/desktop_prefs.rs b/crates/pstr-app/src/desktop_prefs.rs new file mode 100644 index 0000000..bfd0ff2 --- /dev/null +++ b/crates/pstr-app/src/desktop_prefs.rs @@ -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::(&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); + } +} diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index a90b007..c2696d7 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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, /// Whether the player page is on screen. See [`Engine::wake`]. picture_shown: Arc, + /// Settings only the desktop has, and the file they are kept in. + desktop: Arc>, + desktop_file: Arc, /// Bumped to stop the crawls in flight; each watches it from its start. crawl_cancel: Arc>, events: Sender, @@ -371,6 +376,7 @@ impl Engine { ) -> anyhow::Result<(Self, Receiver)> { 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 { + 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() diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index c189f95..1df81ef 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -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; diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index ebb871a..de8a4a7 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -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) diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index 81e68f2..ec144e3 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -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, } /// 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) { } }, ); + 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) { + 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); + } + }, + ); }, ); } diff --git a/crates/pstr-core/src/config.rs b/crates/pstr-core/src/config.rs index 790a256..0312d41 100644 --- a/crates/pstr-core/src/config.rs +++ b/crates/pstr-core/src/config.rs @@ -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") diff --git a/crates/pstr-stream/src/disk.rs b/crates/pstr-stream/src/disk.rs index a39c656..1fe9ba7 100644 --- a/crates/pstr-stream/src/disk.rs +++ b/crates/pstr-stream/src/disk.rs @@ -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, } @@ -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 = { + 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] diff --git a/crates/pstr-stream/src/source.rs b/crates/pstr-stream/src/source.rs index 1dc592d..fae365f 100644 --- a/crates/pstr-stream/src/source.rs +++ b/crates/pstr-stream/src/source.rs @@ -200,6 +200,21 @@ impl StreamSource { pub fn disk_stats(&self) -> Option { 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 {