diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 81c6f8d..4f3cb66 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -426,6 +426,8 @@ pub struct App { /// Which share the crawl is on, and how far it has got. /// Files found so far, by share name, for each share still listing. crawl_progress: std::collections::BTreeMap, + /// The window's size, written at exit for the next launch. + window: crate::window::WindowMemory, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, @@ -447,6 +449,7 @@ impl App { // After the engine, not before: the engine is what reads the stored // theme, and a window that paints one frame in the default palette // before switching is a window that flashes on every launch. + let window = crate::window::WindowMemory::new(dirs.window_file()); let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; theme::install_font_fallbacks(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); @@ -490,6 +493,7 @@ impl App { connecting: true, crawling: false, crawl_progress: Default::default(), + window, downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, @@ -837,10 +841,26 @@ impl App { // Nothing else causes a frame while a film plays and the mouse is // still, and a countdown that only ticks when the pointer moves is // worse than none. - ctx.request_repaint_after(std::time::Duration::from_millis(200)); + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + let still = next.as_ref().and_then(|target| { + let title = self.library.get(&target.title_key)?; + let episode = title.episodes().find(|episode| { + episode.node.share_id == target.share_id && episode.node.link_id == target.link_id + })?; + ui::Art { + engine: &self.engine, + thumbs: &mut self.thumbs, + posters: &mut self.posters, + metadata: &self.metadata, + episodes: &self.episodes, + } + .still(&title.key, episode) + }); Some(UpNextCard { seconds: left, + left: (left / UP_NEXT_SECONDS) as f32, caption: next.map(|target| target.caption()).unwrap_or_default(), + still, }) } @@ -1320,6 +1340,7 @@ impl eframe::App for App { self.pump(ctx, frame); self.warm_next(); self.auto_skip(ctx); + self.window.observe(ctx); let mut actions: Vec = Vec::new(); @@ -1625,6 +1646,7 @@ impl eframe::App for App { /// immediately before tearing the painter down, which is the last moment /// that is true. fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.window.save(); if let Some(playback) = &self.playback { playback.stop_and_wait(std::time::Duration::from_secs(2)); } diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index 14534e1..520401f 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -26,3 +26,4 @@ pub mod playback; pub mod theme; pub mod ui; pub mod video; +pub mod window; diff --git a/crates/pstr-app/src/main.rs b/crates/pstr-app/src/main.rs index bb533d3..b71dfa4 100644 --- a/crates/pstr-app/src/main.rs +++ b/crates/pstr-app/src/main.rs @@ -31,10 +31,12 @@ fn main() -> anyhow::Result<()> { ); let dirs = AppDirs::ensure().context("resolve app directories")?; + let window = pstr_app::window::WindowState::load(&dirs.window_file()); let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("proton-stream") - .with_inner_size([1180.0, 780.0]) + .with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height])) + .with_maximized(window.is_some_and(|state| state.maximized)) .with_min_inner_size([720.0, 480.0]) .with_app_id("io.narl.proton-stream"), ..Default::default() diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index a56b7b5..e5a919c 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -118,8 +118,12 @@ pub struct Chrome<'a> { pub struct UpNextCard { /// Seconds until the next episode starts. pub seconds: f64, + /// The same, as a fraction of the whole countdown, for the ring. + pub left: f32, /// Which episode that is. pub caption: String, + /// Its still, once there is one. + pub still: Option, } impl Overlay { @@ -468,60 +472,114 @@ fn up_next_card( bottom_margin: f32, actions: &mut Vec, ) { - let size = Vec2::new(330.0, 104.0); + const STILL: Vec2 = Vec2::new(128.0, 72.0); + let size = Vec2::new(400.0, 104.0); floating(ui, rect, size, bottom_margin, |ui| { egui::Frame::new() .fill(Color32::from_black_alpha(215)) .stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120))) .corner_radius(CornerRadius::same(8)) - .inner_margin(egui::Margin::symmetric(14, 12)) + .inner_margin(egui::Margin::same(12)) .show(ui, |ui| { - ui.set_width(size.x - 28.0); + ui.set_width(size.x - 24.0); ui.horizontal(|ui| { - ui.label( - theme::Role::Caption - .rich("Up next") - .strong() - .color(Color32::from_white_alpha(200)), + // The still, with the countdown running round it: what is + // coming and when, read in one glance. + let (frame, response) = ui.allocate_exact_size(STILL, Sense::click()); + let painter = ui.painter(); + let radius = CornerRadius::same(6); + match &card.still { + Some(texture) => { + painter.add( + egui::epaint::RectShape::filled(frame, radius, Color32::WHITE) + .with_texture( + texture.id(), + ui::cover_uv(texture.size_vec2(), frame.size()), + ), + ); + } + None => { + painter.rect_filled(frame, radius, Color32::from_white_alpha(20)); + } + } + painter.rect_filled(frame, radius, Color32::from_black_alpha(90)); + countdown_ring(painter, frame.center(), 22.0, card.left); + // Rounded up, so a ring that says "1" is never followed by + // a second of nothing happening. + painter.text( + frame.center(), + Align2::CENTER_CENTER, + card.seconds.ceil() as u32, + theme::Role::Body.font(), + Color32::WHITE, ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Rounded up, so a card that says "1s" is never - // followed by a second of nothing happening. - ui.label( - theme::Role::Caption - .rich(format!("in {}s", card.seconds.ceil() as u32)) - .color(theme::accent()), - ); - }); - }); - ui.add( - egui::Label::new( - theme::Role::Body - .rich(&card.caption) - .strong() - .color(Color32::WHITE), - ) - .truncate(), - ); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui::accent_button(ui, "Play now").clicked() { + if response.on_hover_text("Play now").clicked() { actions.push(Action::PlayAdjacent(Adjacent::Next)); } - ui.add_space(6.0); - if ui - .button("Watch till the end") - .on_hover_text("Play this one out — nothing will be skipped") - .clicked() - { - actions.push(Action::WatchToEnd); - } + + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + theme::Role::Caption + .rich("Up next") + .strong() + .color(Color32::from_white_alpha(200)), + ); + ui.add( + egui::Label::new( + theme::Role::Body + .rich(&card.caption) + .strong() + .color(Color32::WHITE), + ) + .truncate(), + ); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui::accent_button(ui, "Play now").clicked() { + actions.push(Action::PlayAdjacent(Adjacent::Next)); + } + ui.add_space(6.0); + if ui + .button("Watch till the end") + .on_hover_text("Play this one out — nothing will be skipped") + .clicked() + { + actions.push(Action::WatchToEnd); + } + }); + }); }); }); }); } +/// A ring that empties clockwise from twelve o'clock as `left` runs to zero. +fn countdown_ring(painter: &egui::Painter, center: egui::Pos2, radius: f32, left: f32) { + painter.circle_stroke( + center, + radius, + egui::Stroke::new(3.0, Color32::from_white_alpha(60)), + ); + let left = left.clamp(0.0, 1.0); + if left <= 0.0 { + return; + } + let steps = (64.0 * left).ceil().max(2.0) as usize; + let sweep = std::f32::consts::TAU * left; + let points: Vec = (0..=steps) + .map(|step| { + let angle = -std::f32::consts::FRAC_PI_2 + sweep * step as f32 / steps as f32; + center + radius * Vec2::new(angle.cos(), angle.sin()) + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(3.0, theme::accent()), + )); +} + /// Put something in the bottom-right corner, `bottom_margin` above the edge. /// /// The inset matches the controls' own padding rather than being its own diff --git a/crates/pstr-app/src/window.rs b/crates/pstr-app/src/window.rs new file mode 100644 index 0000000..0d87c4c --- /dev/null +++ b/crates/pstr-app/src/window.rs @@ -0,0 +1,124 @@ +//! The window's size, kept between launches. +//! +//! Size and maximized only. Position is left to the window manager: Wayland +//! does not let a client choose it, and on X11 a remembered position on a +//! monitor that has since been unplugged opens the window off screen. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// What the next launch should open at. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct WindowState { + pub width: f32, + pub height: f32, + pub maximized: bool, +} + +/// The smallest size worth restoring. Anything below is a window that was +/// being dragged about when it closed, or a file someone edited by hand. +const SMALLEST: [f32; 2] = [720.0, 480.0]; + +impl WindowState { + /// The saved state, if there is a sensible one. A missing or broken file + /// means the default size, never an error: this is a convenience. + pub fn load(path: &Path) -> Option { + let state: Self = pstr_core::config::read_json(path).ok().flatten()?; + state.sensible().then_some(state) + } + + fn sensible(&self) -> bool { + self.width.is_finite() + && self.height.is_finite() + && self.width >= SMALLEST[0] + && self.height >= SMALLEST[1] + } + + /// Fold this frame's viewport into `last`. + /// + /// A fullscreen window says nothing about how big the window should be, and + /// a maximized one says nothing about how big it should be once it is not, + /// so neither changes the size kept. + pub fn observe(ctx: &egui::Context, last: Option) -> Option { + let (size, maximized, fullscreen) = ctx.input(|input| { + let viewport = input.viewport(); + ( + viewport.inner_rect.map(|rect| rect.size()), + viewport.maximized.unwrap_or(false), + viewport.fullscreen.unwrap_or(false), + ) + }); + if fullscreen { + return last; + } + let (width, height) = match (maximized, last, size) { + (true, Some(last), _) => (last.width, last.height), + (_, _, Some(size)) => (size.x, size.y), + _ => return last, + }; + let state = Self { + width, + height, + maximized, + }; + state.sensible().then_some(state).or(last) + } +} + +/// Writes the state when it changes, once, at exit. +pub struct WindowMemory { + path: PathBuf, + saved: Option, + pub current: Option, +} + +impl WindowMemory { + pub fn new(path: PathBuf) -> Self { + let saved = WindowState::load(&path); + Self { + path, + saved, + current: saved, + } + } + + pub fn observe(&mut self, ctx: &egui::Context) { + self.current = WindowState::observe(ctx, self.current); + } + + pub fn save(&self) { + let Some(current) = self.current.filter(|current| Some(*current) != self.saved) else { + return; + }; + if let Err(error) = pstr_core::config::write_json(&self.path, ¤t) { + tracing::debug!("save the window size: {error}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_window_smaller_than_the_minimum_is_not_restored() { + let path = std::env::temp_dir().join(format!("pstr-window-{}.json", std::process::id())); + let tiny = WindowState { + width: 200.0, + height: 100.0, + maximized: false, + }; + pstr_core::config::write_json(&path, &tiny).unwrap(); + assert_eq!(WindowState::load(&path), None); + + let normal = WindowState { + width: 1400.0, + height: 900.0, + maximized: true, + }; + pstr_core::config::write_json(&path, &normal).unwrap(); + assert_eq!(WindowState::load(&path), Some(normal)); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/pstr-core/src/config.rs b/crates/pstr-core/src/config.rs index b53d744..790a256 100644 --- a/crates/pstr-core/src/config.rs +++ b/crates/pstr-core/src/config.rs @@ -73,6 +73,11 @@ impl AppDirs { Ok(()) } + /// The desktop window's size, for the next launch to open at. + pub fn window_file(&self) -> PathBuf { + self.config.join("window.json") + } + /// The share list. pub fn shares_file(&self) -> PathBuf { self.config.join("shares.json")