mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
935a985589
commit
a432a93cf9
9 files changed
+315
-5
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user