From 4027ab5fb307abf430981b36953d53735f29658f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 19:27:28 +0200 Subject: [PATCH] feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames - toasts replace the status line; keyboard shortcuts with a ? overlay - library: filters, sort, row virtualisation, cached search, skeleton tiles until the catalog loads, gradient placeholder art - title page: season pills, episode rows with stills and one download glyph, portrait poster, clamped overview, resume time in the button - player: fading chrome, smooth scrims, subtitles lifted over the controls, drag-following seek knob, OSD, speed menu, auto-skip - settings as grouped cards with toggles and palette previews - shares: confirm before removing, inline link validation, form kept until the add succeeds; downloads scroll and show rate and ETA - downloads pipeline four blocks; the next episode is warmed near the end - a film behind another page repaints at 4 Hz, not its frame rate Fixes B55-B60. --- crates/pstr-app/examples/embedded_video.rs | 7 +- crates/pstr-app/src/app.rs | 738 ++++++++++++++++++--- crates/pstr-app/src/engine.rs | 207 +++++- crates/pstr-app/src/playback.rs | 47 +- crates/pstr-app/src/theme.rs | 30 + crates/pstr-app/src/ui/downloads.rs | 330 +++++---- crates/pstr-app/src/ui/library.rs | 160 ++++- crates/pstr-app/src/ui/matcher.rs | 3 +- crates/pstr-app/src/ui/mod.rs | 310 ++++++++- crates/pstr-app/src/ui/player.rs | 158 +++-- crates/pstr-app/src/ui/settings.rs | 407 +++++++----- crates/pstr-app/src/ui/shares.rs | 149 ++++- crates/pstr-app/src/ui/shortcuts.rs | 80 +++ crates/pstr-app/src/ui/title.rs | 717 ++++++++++++++------ crates/pstr-app/src/ui/toast.rs | 207 ++++++ crates/pstr-app/src/ui/transport.rs | 116 +++- crates/pstr-app/src/ui/widgets.rs | 361 ++++++++++ crates/pstr-app/src/video.rs | 10 +- docs/BUGS.md | 97 +++ docs/DESIGN-TOKENS.md | 18 + 20 files changed, 3389 insertions(+), 763 deletions(-) create mode 100644 crates/pstr-app/src/ui/shortcuts.rs create mode 100644 crates/pstr-app/src/ui/toast.rs create mode 100644 crates/pstr-app/src/ui/widgets.rs diff --git a/crates/pstr-app/examples/embedded_video.rs b/crates/pstr-app/examples/embedded_video.rs index 6cb4aae..d6d18ef 100644 --- a/crates/pstr-app/examples/embedded_video.rs +++ b/crates/pstr-app/examples/embedded_video.rs @@ -92,7 +92,12 @@ impl Harness { .context("eframe was built without a glow context")?; // SAFETY: `CreationContext` is handed to us on the UI thread with the // context current, which is the same guarantee `App::ui` gives. - let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?; + let video = unsafe { + VideoSurface::new(Arc::clone(&player), gl, { + let ctx = cc.egui_ctx.clone(); + move || ctx.request_repaint() + }) + }?; Ok(Self { player, diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 58234b2..76a3714 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -22,9 +22,6 @@ use crate::playback::{Playback, PlaybackTarget}; use crate::ui::player::UpNextCard; use crate::{theme, ui}; -/// How long a status line stays up before it fades on its own. -const STATUS_SECONDS: f64 = 6.0; - /// A frame slower than this is not a slow frame, it is a hang. /// /// The UI thread has only a handful of blocking calls in it — building an mpv @@ -111,7 +108,9 @@ pub enum Action { url: String, password: Option, }, + /// Ask whether to forget a share. [`Action::ForgetShare`] does it. RemoveShare(String), + ForgetShare(String), /// Flip an episode between seen and unseen by hand. SetWatched { share_id: String, @@ -156,6 +155,10 @@ pub enum Action { PlayAdjacent(Adjacent), /// Start or stop the next episode playing on its own at the end of one. SetAutoplay(bool), + /// Play faster or slower, and keep doing so for the next file. + SetSpeed(f64), + /// Seek past openings and credits without being asked. + SetAutoSkip(bool), /// Repaint the window in a different palette. SetAppearance(Appearance), /// In or out of fullscreen, from the player page. @@ -165,6 +168,14 @@ pub enum Action { LeavePlayer, /// Call off the end-of-episode countdown and let this file play out. WatchToEnd, + /// Put the caret in the search box, from anywhere in the library. + FocusSearch, + /// Show a different slice of the library, or in a different order. + SetShelf(Filter, Sort), + /// Open or close the list of keyboard shortcuts. + ToggleShortcuts, + /// One page back: a title to the library, the library to nowhere. + Back, } /// The end-of-episode countdown. @@ -197,13 +208,6 @@ pub enum Adjacent { Next, } -/// A line at the bottom of the window, for a few seconds. -pub struct Status { - pub text: String, - pub error: bool, - pub at: f64, -} - /// Picking a title's entry by hand. /// /// The escape hatch from the matcher, which is deliberately unwilling to guess: @@ -250,6 +254,108 @@ impl Matcher { } } +/// What the library page lists, worked out once per change rather than once a +/// frame. +/// +/// A search lowercases every filename in the library, and the shelf sorts +/// every title by when it was last played. Neither is slow once; both were +/// being done on every frame, and while a film plays under the transport bar +/// that is the film's frame rate. +#[derive(Default)] +pub struct LibraryView { + /// The query `matches` answers, as typed. + query: String, + /// Which titles the grid shows, and in what order. + pub filter: Filter, + pub sort: Sort, + /// The filter and order `matches` was built with. + built_with: (Filter, Sort), + /// Indices into [`Library::titles`]. + pub matches: Vec, + /// The "Continue watching" shelf, most recent first, as indices. + pub resumable: Vec, + /// The library changed under the lists. + stale: bool, +} + +/// Which titles the grid shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Filter { + #[default] + All, + Series, + Films, + /// Started and not finished. + Watching, + /// Not started. + Unwatched, +} + +impl Filter { + fn admits(self, title: &pstr_core::library::Title) -> bool { + use pstr_core::library::TitleKind; + let watched = title.watched_count(); + match self { + Self::All => true, + Self::Series => title.kind == TitleKind::Series, + Self::Films => title.kind == TitleKind::Film, + Self::Watching => { + title.resume().is_some() || (watched > 0 && watched < title.episode_count()) + } + Self::Unwatched => watched == 0 && title.resume().is_none(), + } + } +} + +/// What order the grid is in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Sort { + /// The library's own order: by name, ignoring a leading article. + #[default] + Name, + /// Most recently played first. + Recent, + /// Newest first, undated last. + Year, +} + +impl LibraryView { + fn refresh(&mut self, library: &Library, query: &str) { + if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) { + return; + } + let index_of = |title: &pstr_core::library::Title| { + library + .titles + .iter() + .position(|candidate| std::ptr::eq(candidate, title)) + }; + let mut matches: Vec<&pstr_core::library::Title> = library + .search(query) + .into_iter() + .filter(|title| self.filter.admits(title)) + .collect(); + match self.sort { + Sort::Name => {} + // Stable, so titles never played keep their alphabetical order + // after the ones that have been. + Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())), + Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))), + } + self.matches = matches.into_iter().filter_map(index_of).collect(); + self.built_with = (self.filter, self.sort); + if self.stale { + self.resumable = library + .continue_watching() + .into_iter() + .filter_map(index_of) + .collect(); + } + self.query = query.to_owned(); + self.stale = false; + } +} + /// The share the viewer is typing in. #[derive(Default)] pub struct ShareForm { @@ -257,6 +363,11 @@ pub struct ShareForm { pub url: String, pub has_password: bool, pub password: String, + /// Sent, and not answered yet. The form keeps what was typed until it is, + /// so a refused link can be corrected rather than pasted again. + pub sending: bool, + /// Why the last one was refused. + pub error: Option, } pub struct App { @@ -281,12 +392,28 @@ pub struct App { /// The hand-matching dialog, while it is open. pub matcher: Option, pub search: String, + /// Set for the frame the search box should take the caret. + focus_search: bool, + /// Whether the list of keyboard shortcuts is open. + pub shortcuts_open: bool, + /// What a key just did in the player, and when: "Volume 55%", "+30 s". + osd: Option<(String, f64)>, + pub view: LibraryView, + /// Whether the catalog has been read at all yet. Until it has, an empty + /// library means "not loaded", not "nothing in it". + pub loaded: bool, pub form: ShareForm, pub playback: Option, /// Whether the player page's controls are showing, and why. pub overlay: ui::player::Overlay, /// The end-of-episode countdown for whatever is playing. pub up_next: UpNext, + /// The player whose next episode has already been fetched ahead, so it is + /// asked for once per file rather than once a frame. + warmed: u64, + /// Chapters already skipped on their own in this player, so seeking back + /// into an opening on purpose is not undone a frame later. + auto_skipped: (u64, Vec), /// Set while the window is fullscreen, so `F` can toggle rather than only /// ever entering. egui has no way to ask the platform. pub fullscreen: bool, @@ -294,10 +421,15 @@ pub struct App { pub opening: Option, pub connecting: bool, pub crawling: bool, + /// Which share the crawl is on, and how far it has got. + crawl_progress: Option<(String, usize)>, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, - pub status: Option, + /// A share the viewer asked to remove, waiting on their say-so. + pub confirm_remove_share: Option, + /// Messages in the corner. See [`ui::toast`]. + pub toasts: ui::toast::Toasts, /// The frame ceiling, where this session is pacing itself rather than /// letting vsync do it — see [`crate::pacing`]. pacer: Pacer, @@ -339,28 +471,42 @@ impl App { matching: false, matcher: None, search: String::new(), + focus_search: false, + shortcuts_open: false, + osd: None, + view: LibraryView::default(), + loaded: false, form: ShareForm::default(), playback: None, overlay: ui::player::Overlay::default(), up_next: UpNext::default(), + warmed: 0, + auto_skipped: (0, Vec::new()), fullscreen: false, opening: None, connecting: true, crawling: false, + crawl_progress: None, downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, - status: None, + confirm_remove_share: None, + toasts: ui::toast::Toasts::default(), pacer: Pacer::new(), }) } + /// Say over the picture what a control just did. Only on the player page; + /// anywhere else the transport bar already shows it. + fn flash(&mut self, ctx: &egui::Context, text: impl Into) { + if self.page == Page::Player { + self.osd = Some((text.into(), ctx.input(|input| input.time))); + } + } + fn note(&mut self, ctx: &egui::Context, text: impl Into, error: bool) { - self.status = Some(Status { - text: text.into(), - error, - at: ctx.input(|input| input.time), - }); + let now = ctx.input(|input| input.time); + self.toasts.push(now, text, error); } /// Drain everything the background side has said since the last frame. @@ -373,6 +519,16 @@ impl App { while let Ok(event) = self.events.try_recv() { match event { Event::Shares(shares) => self.shares = shares, + Event::ShareAdded(name) => { + self.form = ShareForm::default(); + self.note(ctx, format!("added {name}"), false); + } + Event::ShareRejected(error) => { + // Nothing was crawled, so nothing will say it finished. + self.crawling = false; + self.form.sending = false; + self.form.error = Some(error); + } Event::Connected { failures } => { self.connecting = false; if let Some(text) = describe_failures(&failures) { @@ -383,7 +539,11 @@ impl App { self.connecting = false; self.note(ctx, format!("could not open the shares: {error}"), true); } - Event::LibraryLoaded(library) => self.library = library, + Event::LibraryLoaded(library) => { + self.library = library; + self.loaded = true; + self.view.stale = true; + } Event::Crawled { share_id, nodes, @@ -397,7 +557,14 @@ impl App { false, ); } - Event::CrawlFinished => self.crawling = false, + Event::CrawlFinished => { + self.crawling = false; + self.crawl_progress = None; + } + Event::CrawlProgress { share, found } => { + self.crawling = true; + self.crawl_progress = Some((share, found)); + } Event::Thumbnail { key, image } => self.thumbs.insert(ctx, key, image), Event::ThumbnailMissing { key } => self.thumbs.mark_missing(key), Event::Poster { key, image } => self.posters.insert(ctx, key, image), @@ -405,8 +572,13 @@ impl App { Event::Metadata(records) => { // Artwork keyed by a title whose record changed may now // point somewhere else, and a title that was a miss may now - // have a poster to ask for. - self.posters.clear(); + // have a poster to ask for — so those are forgotten. Only + // those: clearing the lot sent every tile in the library + // back to its initials after each match run, to fade the + // very same picture in again. + for key in changed_art(&self.metadata, &records) { + self.posters.forget(&key); + } self.metadata = records; } Event::EpisodeMetadata(episodes) => self.episodes = episodes, @@ -470,7 +642,7 @@ impl App { self.playback = None; let torn_down = swap.elapsed(); let gl = frame.gl().cloned(); - let started = Playback::start(&self.engine, *target, stream, gl.as_ref(), ctx); + let started = Playback::start(&self.engine, *target, stream, gl.as_ref()); if swap.elapsed() >= STALL { tracing::warn!( "swapping players held the ui thread for {} ms, {} ms of it \ @@ -646,6 +818,65 @@ impl App { }) } + /// Near the end of a file, fetch the opening blocks of the next one. + /// + /// The last two minutes, or the credits if the chapters say where they + /// are — whichever comes first. Late enough that a viewer who stops after + /// one episode has not cost a download, early enough that the swap starts + /// from the cache. + fn warm_next(&mut self) { + /// How close to the end counts as "about to want the next one". + const LEAD_SECONDS: f64 = 120.0; + + let Some(playback) = &self.playback else { + return; + }; + if playback.id == self.warmed || !playback.loaded { + return; + } + let near_end = playback + .duration + .is_some_and(|duration| duration - playback.position < LEAD_SECONDS); + if !(near_end || playback.in_credits()) { + return; + } + self.warmed = playback.id; + if let Some(next) = self.adjacent(Adjacent::Next) { + self.engine.warm(&next); + } + } + + /// Seek past an opening or credits on arrival, when the viewer asked for + /// that. + /// + /// Once per chapter per file. A viewer who skipped back into the opening + /// wants to hear it. + fn auto_skip(&mut self, ctx: &egui::Context) { + let Some(playback) = &self.playback else { + return; + }; + if !playback.loaded || playback.paused || playback.seeking { + return; + } + let Some((label, end)) = playback.skippable() else { + return; + }; + let Some(index) = playback.chapter().map(|chapter| chapter.index) else { + return; + }; + if self.auto_skipped.0 != playback.id { + self.auto_skipped = (playback.id, Vec::new()); + } + if self.auto_skipped.1.contains(&index) || !self.engine.playback_prefs().auto_skip { + return; + } + self.auto_skipped.1.push(index); + playback.send(crate::playback::Command::SeekTo(end)); + // Said, because a jump nobody asked for this second reads as a glitch. + let what = label.trim_start_matches("Skip ").to_lowercase(); + self.note(ctx, format!("skipped the {what}"), false); + } + /// Start the next episode, if there is one and the viewer wants it. /// /// Called on a clean end of file only. A file that failed to load has an @@ -777,6 +1008,24 @@ impl App { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); } Action::WatchToEnd => self.up_next.dismissed = true, + Action::FocusSearch => { + if !matches!(self.page, Page::Library | Page::Title(_)) { + self.page = Page::Library; + } + self.focus_search = true; + } + Action::ToggleShortcuts => self.shortcuts_open = !self.shortcuts_open, + Action::SetShelf(filter, sort) => { + self.view.filter = filter; + self.view.sort = sort; + } + Action::Back => match self.page { + Page::Title(_) => self.page = Page::Library, + // Back out of a search before out of anything else: it is the + // last thing the viewer did. + Page::Library if !self.search.is_empty() => self.search.clear(), + _ => {} + }, Action::LeavePlayer => { if self.fullscreen { self.fullscreen = false; @@ -798,10 +1047,13 @@ impl App { password, } => { self.crawling = true; + self.form.sending = true; + self.form.error = None; self.engine.add_share(name, url, password); - self.form = ShareForm::default(); } - Action::RemoveShare(id) => { + Action::RemoveShare(id) => self.confirm_remove_share = Some(id), + Action::ForgetShare(id) => { + self.confirm_remove_share = None; self.thumbs.clear(); self.engine.remove_share(id); } @@ -826,6 +1078,16 @@ impl App { self.engine.load_library(); } Action::Player(command) => { + if let crate::playback::Command::SeekBy(seconds) = command { + self.flash( + ctx, + if seconds < 0.0 { + format!("− {:.0} s", -seconds) + } else { + format!("+ {seconds:.0} s") + }, + ); + } if let Some(playback) = &self.playback { playback.send(command); } @@ -841,6 +1103,27 @@ impl App { false, ), }, + Action::SetSpeed(speed) => { + let mut prefs = self.engine.playback_prefs(); + prefs.speed = speed; + self.engine.set_playback_prefs(prefs, true); + let speed = self.engine.playback_prefs().speed; + if let Some(playback) = &mut self.playback { + playback.speed = speed; + playback.send(crate::playback::Command::SetSpeed(speed)); + } + let text = format!("Speed {}", ui::format_speed(speed)); + if self.page == Page::Player { + self.flash(ctx, text); + } else { + self.note(ctx, text, false); + } + } + Action::SetAutoSkip(auto_skip) => { + let mut prefs = self.engine.playback_prefs(); + prefs.auto_skip = auto_skip; + self.engine.set_playback_prefs(prefs, true); + } Action::SetAutoplay(autoplay) => { let mut prefs = self.engine.playback_prefs(); prefs.autoplay_next = autoplay; @@ -850,7 +1133,13 @@ impl App { self.engine.set_appearance(appearance); theme::apply(ctx, appearance); } - Action::SetVolume { volume, commit } => self.set_volume(volume, commit), + Action::SetVolume { volume, commit } => { + self.set_volume(volume, commit); + // A slider shows its own value; a key press shows it here. + if commit { + self.flash(ctx, format!("Volume {volume:.0}%")); + } + } Action::ToggleMute => { let mut prefs = self.engine.playback_prefs(); // From what is playing, not from the preferences: mpv is the @@ -861,6 +1150,7 @@ impl App { None => !prefs.muted, }; self.send_player(crate::playback::Command::SetMuted(prefs.muted)); + self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" }); self.engine.set_playback_prefs(prefs, true); } Action::SelectTrack { kind, id } => { @@ -907,7 +1197,13 @@ impl App { } /// The bar across the top: where you are, and what to search. - fn navigation(&self, ui: &mut egui::Ui, actions: &mut Vec, search: &mut String) { + fn navigation( + &self, + ui: &mut egui::Ui, + actions: &mut Vec, + search: &mut String, + focus_search: bool, + ) { ui.horizontal(|ui| { ui.add_space(4.0); ui.label( @@ -955,7 +1251,11 @@ impl App { ui.add_space(4.0); if self.crawling { ui.add(egui::Spinner::new().size(16.0)); - ui.label(ui::muted("crawling")); + ui.label(ui::muted(match &self.crawl_progress { + Some((share, 0)) => format!("crawling {share}"), + Some((share, found)) => format!("crawling {share} · {found} found"), + None => "crawling".to_owned(), + })); } else if self.matching { ui.add(egui::Spinner::new().size(16.0)); ui.label(ui::muted("matching")); @@ -972,11 +1272,7 @@ impl App { if on_library && !self.library.is_empty() { ui.add_space(8.0); - ui.add( - egui::TextEdit::singleline(search) - .hint_text("Search") - .desired_width(220.0), - ); + ui::search_field(ui, search, focus_search); } }); }); @@ -993,6 +1289,8 @@ impl eframe::App for App { let ctx = ui.ctx().clone(); let ctx = &ctx; self.pump(ctx, frame); + self.warm_next(); + self.auto_skip(ctx); let mut actions: Vec = Vec::new(); @@ -1002,14 +1300,16 @@ impl eframe::App for App { if self.page == Page::Player && self.playback.is_none() && self.opening.is_none() { self.page = Page::Library; } + self.engine.set_picture_shown(self.page == Page::Player); if self.page == Page::Player { self.overlay.observe(ctx); - let volume = self - .playback - .as_ref() - .map_or(0.0, |playback| playback.volume); - shortcuts(ctx, self.fullscreen, volume, &mut actions); + let state = self.playback.as_ref().map(|playback| PlayerKeys { + volume: playback.volume, + speed: playback.speed, + skip_to: playback.skippable().map(|(_, end)| end), + }); + shortcuts(ctx, self.fullscreen, state, &mut actions); // Before the draw, because drawing does not mutate — and it can // push an action of its own, which is why it takes the same list. @@ -1039,6 +1339,12 @@ impl eframe::App for App { &mut actions, ); }); + if let Some((text, at)) = &self.osd + && !ui::player::osd(ctx, text, *at) + { + self.osd = None; + } + self.toasts.show(ctx, self.overlay.covered()); for action in actions { self.apply(ctx, action); @@ -1046,13 +1352,25 @@ impl eframe::App for App { return; } + // Not under a dialog: Escape there closes the dialog, and should not + // also leave the page behind it. + let dialog = self.matcher.is_some() + || self.confirm_partial_delete.is_some() + || self.confirm_remove_share.is_some() + || self.shortcuts_open; + if !dialog { + global_shortcuts(ctx, &self.page, &mut actions); + } + // Split the borrow up front: the pages get read-only state and a place // to put actions, which is what keeps them from mutating mid-draw. { let mut search = std::mem::take(&mut self.search); let neighbours = self.neighbours(); + let playback_prefs = self.engine.playback_prefs(); let prefs = ui::settings::Prefs { - autoplay: self.engine.playback_prefs().autoplay_next, + autoplay: playback_prefs.autoplay_next, + auto_skip: playback_prefs.auto_skip, appearance: self.engine.appearance(), }; @@ -1070,7 +1388,7 @@ impl eframe::App for App { .show(ui, |ui| { let shadow = ui.painter().add(egui::Shape::Noop); let background = ui.painter().add(egui::Shape::Noop); - self.navigation(ui, &mut actions, &mut search); + self.navigation(ui, &mut actions, &mut search, self.focus_search); // Full width from the space the panel was given, height from // the space its contents took. let content = ui.min_rect(); @@ -1090,6 +1408,8 @@ impl eframe::App for App { ); }); self.search = search; + self.focus_search = false; + self.view.refresh(&self.library, &self.search); let App { engine, @@ -1103,19 +1423,22 @@ impl eframe::App for App { settings, api_key, search, + view, + loaded, form, matcher, playback, opening, - status, downloads, offline_files, confirm_partial_delete, + confirm_remove_share, .. } = self; + let mut covered = 0.0; if playback.is_some() || opening.is_some() { - egui::Panel::bottom("transport") + covered = egui::Panel::bottom("transport") .resizable(false) .frame( egui::Frame::new() @@ -1146,33 +1469,10 @@ impl eframe::App for App { neighbours, &mut actions, ); - }); - } - - if let Some(line) = status { - let age = ctx.input(|input| input.time) - line.at; - if age < STATUS_SECONDS { - egui::Panel::bottom("status") - .resizable(false) - .frame( - egui::Frame::new() - .fill(theme::background()) - .inner_margin(egui::Margin::symmetric(16, 6)), - ) - .show(ui, |ui| { - let colour = if line.error { - theme::danger() - } else { - theme::muted() - }; - ui.label(theme::Role::Caption.rich(&line.text).color(colour)); - }); - // Repaint once the line is due to disappear, or it lingers - // until something else happens to cause a frame. - ctx.request_repaint_after(std::time::Duration::from_secs_f64( - STATUS_SECONDS - age, - )); - } + }) + .response + .rect + .height(); } egui::CentralPanel::default() @@ -1193,9 +1493,17 @@ impl eframe::App for App { episodes, }; match page { - Page::Library => { - ui::library::show(ui, &mut art, library, search, &mut actions) - } + Page::Library => ui::library::show( + ui, + &mut art, + ui::library::Shelves { + library, + view, + loaded: *loaded, + search, + }, + &mut actions, + ), Page::Title(key) => ui::title::show( ui, &mut art, @@ -1207,7 +1515,7 @@ impl eframe::App for App { }, &mut actions, ), - Page::Shares => ui::shares::show(ui, shares, form, &mut actions), + Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions), Page::Settings => { ui::settings::show(ui, settings, prefs, api_key, &mut actions) } @@ -1232,25 +1540,46 @@ impl eframe::App for App { } if let Some(key) = confirm_partial_delete.clone() { - egui::Window::new("Delete partial download?") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .show(ctx, |ui| { - ui.label( - "This discards downloaded partial bytes. The online source is not changed.", - ); - ui.horizontal(|ui| { - if ui.button("Keep partial").clicked() { - *confirm_partial_delete = None; - } - if ui.button("Delete partial").clicked() { - engine.remove_download(key.clone(), true); - *confirm_partial_delete = None; - } - }); - }); + match ui::confirm( + ctx, + "partial", + "Delete the partial download?", + "What has been downloaded so far is discarded. The file in the share is \ + not touched.", + "Delete", + ) { + ui::Answer::Confirmed => { + engine.remove_download(key, true); + *confirm_partial_delete = None; + } + ui::Answer::Declined => *confirm_partial_delete = None, + ui::Answer::Pending => {} + } } + + if let Some(id) = confirm_remove_share.clone() { + let name = shares + .iter() + .find(|share| share.id == id) + .map_or(id.as_str(), |share| share.name.as_str()); + match ui::confirm( + ctx, + "remove-share", + &format!("Remove {name}?"), + "Its titles leave the library, along with what you have watched of them. \ + Its downloads are deleted, and the link and password are removed from the \ + keyring.", + "Remove", + ) { + ui::Answer::Confirmed => actions.push(Action::ForgetShare(id)), + ui::Answer::Declined => *confirm_remove_share = None, + ui::Answer::Pending => {} + } + } + if self.shortcuts_open && ui::shortcuts::show(ctx) { + actions.push(Action::ToggleShortcuts); + } + self.toasts.show(ctx, covered); } for action in actions { @@ -1276,12 +1605,124 @@ impl eframe::App for App { } } +/// Titles whose tile art is not what it was. +/// +/// Compared by URL: the same URL is the same picture, and anything else — +/// a different one, a new one, or none any more — has to be fetched or +/// dropped. +fn changed_art( + before: &HashMap, + after: &HashMap, +) -> Vec { + let url = |records: &HashMap, key: &str| { + records + .get(key) + .and_then(|record| record.metadata.as_ref()) + .and_then(|metadata| metadata.tile_art()) + .map(|(url, _)| url.to_owned()) + }; + let mut keys: Vec = before + .keys() + .chain(after.keys()) + .filter(|key| url(before, key) != url(after, key)) + .cloned() + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +/// Keys every page but the player answers to. +/// +/// Plain keys — `/`, `?` — only while nothing is being typed into, or a search +/// for "a/b" would jump to the search box halfway through it. +fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec) { + use egui::{Key, Modifiers}; + + let typing = ctx.egui_wants_keyboard_input(); + let mut tab = None; + ctx.input_mut(|input| { + if input.consume_key(Modifiers::COMMAND, Key::F) + || (!typing && input.key_pressed(Key::Slash)) + { + actions.push(Action::FocusSearch); + } + if input.consume_key(Modifiers::NONE, Key::F5) + || input.consume_key(Modifiers::COMMAND, Key::R) + { + actions.push(Action::Crawl(None)); + } + if !typing && input.key_pressed(Key::Questionmark) { + actions.push(Action::ToggleShortcuts); + } + if input.consume_key(Modifiers::ALT, Key::ArrowLeft) + || input.pointer.button_pressed(egui::PointerButton::Extra1) + || (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape)) + || (!typing && *page == Page::Library && input.key_pressed(Key::Backspace)) + { + actions.push(Action::Back); + } + for (key, index) in [ + (Key::Num1, 0), + (Key::Num2, 1), + (Key::Num3, 2), + (Key::Num4, 3), + ] { + if input.consume_key(Modifiers::COMMAND, key) { + tab = Some(index); + } + } + if input.consume_key(Modifiers::COMMAND, Key::Comma) { + tab = Some(3); + } + }); + if let Some(index) = tab { + let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone(); + actions.push(Action::Goto(page)); + } +} + /// Keys the player page answers to. /// /// Only there: elsewhere space is a button press and the arrows move between /// widgets, and taking them would break the rest of the app to serve a page /// that is not on screen. -fn shortcuts(ctx: &egui::Context, fullscreen: bool, volume: f64, actions: &mut Vec) { +/// What the player keys need to know about what is playing. +struct PlayerKeys { + volume: f64, + speed: f64, + /// Where the skip button would go, while there is one. + skip_to: Option, +} + +/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a +/// tenth, because 1.0 has to be somewhere a viewer can land on again. +const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0]; + +/// The next step from `speed` in `direction`, from wherever it is now. +fn step_speed(speed: f64, faster: bool) -> f64 { + if faster { + SPEEDS + .iter() + .copied() + .find(|&step| step > speed + 1e-6) + .unwrap_or(SPEEDS[SPEEDS.len() - 1]) + } else { + SPEEDS + .iter() + .rev() + .copied() + .find(|&step| step < speed - 1e-6) + .unwrap_or(SPEEDS[0]) + } +} + +fn shortcuts( + ctx: &egui::Context, + fullscreen: bool, + playing: Option, + actions: &mut Vec, +) { use crate::playback::Command; use egui::Key; @@ -1301,6 +1742,9 @@ fn shortcuts(ctx: &egui::Context, fullscreen: bool, volume: f64, actions: &mut V Key::N, Key::P, Key::F, + Key::S, + Key::OpenBracket, + Key::CloseBracket, Key::Escape, ] .into_iter() @@ -1308,6 +1752,7 @@ fn shortcuts(ctx: &egui::Context, fullscreen: bool, volume: f64, actions: &mut V .collect() }); + let volume = playing.as_ref().map_or(0.0, |playing| playing.volume); for key in pressed { match key { Key::Space | Key::K => actions.push(Action::Player(Command::TogglePause)), @@ -1327,6 +1772,19 @@ fn shortcuts(ctx: &egui::Context, fullscreen: bool, volume: f64, actions: &mut V Key::N => actions.push(Action::PlayAdjacent(Adjacent::Next)), Key::P => actions.push(Action::PlayAdjacent(Adjacent::Previous)), Key::F => actions.push(Action::ToggleFullscreen), + Key::S => { + if let Some(end) = playing.as_ref().and_then(|playing| playing.skip_to) { + actions.push(Action::Player(Command::SeekTo(end))); + } + } + Key::OpenBracket | Key::CloseBracket => { + if let Some(playing) = &playing { + actions.push(Action::SetSpeed(step_speed( + playing.speed, + key == Key::CloseBracket, + ))); + } + } // Escape leaves fullscreen first and the page second, which is what // it does everywhere else and what stops one press from both // un-maximising the window and hiding the film. @@ -1358,3 +1816,101 @@ fn transport_panel( } } } + +#[cfg(test)] +mod tests { + use pstr_core::library::TitleKind; + use pstr_core::metadata::{ProviderId, TitleMetadata}; + + use super::*; + + fn record(key: &str, poster: Option<&str>) -> (String, MetadataRecord) { + let metadata = poster.map(|url| TitleMetadata { + provider: ProviderId::AniList, + remote_id: key.into(), + name: key.into(), + original_name: None, + overview: None, + year: None, + kind: TitleKind::Series, + poster_url: Some(url.into()), + backdrop_url: None, + rating: None, + genres: Vec::new(), + episodes: None, + url: None, + }); + ( + key.into(), + MetadataRecord { + title_key: key.into(), + provider: ProviderId::AniList, + metadata, + fetched_at: 0, + manual: false, + }, + ) + } + + #[test] + fn the_filters_split_the_library_by_kind() { + use pstr_core::library::{Title, TitleKind}; + let title = |kind| Title { + key: "k".into(), + name: "n".into(), + year: None, + kind, + seasons: Vec::new(), + share_ids: Vec::new(), + }; + let film = title(TitleKind::Film); + let series = title(TitleKind::Series); + assert!(Filter::All.admits(&film) && Filter::All.admits(&series)); + assert!(Filter::Films.admits(&film) && !Filter::Films.admits(&series)); + assert!(Filter::Series.admits(&series) && !Filter::Series.admits(&film)); + // Nothing watched of either: not started, and not being watched. + assert!(Filter::Unwatched.admits(&film)); + assert!(!Filter::Watching.admits(&film)); + } + + #[test] + fn speed_steps_land_back_on_normal() { + assert_eq!(step_speed(1.0, true), 1.25); + assert_eq!(step_speed(1.25, false), 1.0); + // From somewhere between steps — a speed set on Android — to the + // nearest step in the direction asked. + assert_eq!(step_speed(1.1, true), 1.25); + assert_eq!(step_speed(1.1, false), 1.0); + assert_eq!(step_speed(3.0, true), 3.0); + assert_eq!(step_speed(0.5, false), 0.5); + } + + #[test] + fn a_match_run_that_changes_nothing_keeps_every_poster() { + let before: HashMap<_, _> = [record("a", Some("x")), record("b", None)].into(); + let after = before.clone(); + assert!(changed_art(&before, &after).is_empty()); + } + + #[test] + fn only_titles_whose_art_moved_lose_their_poster() { + let before: HashMap<_, _> = [ + record("same", Some("x")), + record("moved", Some("old")), + record("dropped", Some("y")), + record("found", None), + ] + .into(); + let after: HashMap<_, _> = [ + record("same", Some("x")), + record("moved", Some("new")), + record("found", Some("z")), + record("added", Some("w")), + ] + .into(); + assert_eq!( + changed_art(&before, &after), + vec!["added", "dropped", "found", "moved"] + ); + } +} diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index f0301de..2596d59 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -80,9 +80,17 @@ pub struct DownloadItem { pub state: DownloadState, pub downloaded: u64, pub total: u64, + /// Bytes a second, smoothed. Only meaningful while it is running. + pub rate: f64, } impl DownloadItem { + /// Seconds until done at the current rate, while that is knowable. + pub fn eta(&self) -> Option { + (self.state == DownloadState::Running && self.rate > 0.0 && self.total > self.downloaded) + .then(|| (self.total - self.downloaded) as f64 / self.rate) + } + pub fn percent(&self) -> f32 { if self.total == 0 { 0.0 @@ -153,6 +161,10 @@ const THUMBNAIL_MAX_EDGE: u32 = 640; pub enum Event { /// The share list changed on disk. Shares(Vec), + /// The share the form asked for is stored; its crawl is starting. + ShareAdded(String), + /// The share the form asked for was refused, with why. Nothing was crawled. + ShareRejected(String), /// Shares opened; playback is possible from here. Connected { /// Shares that did not open, with why. @@ -169,6 +181,8 @@ pub enum Event { files: usize, seconds: f64, }, + /// A crawl is under way: which share, and how many nodes so far. + CrawlProgress { share: String, found: usize }, /// Every requested crawl is done. CrawlFinished, /// A decoded Proton thumbnail, keyed as [`thumbnail_key`]. @@ -287,6 +301,8 @@ pub struct Engine { track_prefs: Arc>>, downloads: Arc>, download_permits: Arc, + /// Whether the player page is on screen. See [`Engine::wake`]. + picture_shown: Arc, events: Sender, ctx: egui::Context, } @@ -297,6 +313,10 @@ const THUMBNAIL_CONCURRENCY: usize = 6; /// How many provider lookups may be in flight at once. See [`Engine::lookups`]. const LOOKUP_CONCURRENCY: usize = 2; const DOWNLOAD_CONCURRENCY: usize = 3; +/// How many blocks of one download are fetched at once. Four is what the +/// stream layer itself allows a single read, for the same reason: enough to +/// hide a round trip, few enough that three downloads do not swamp a player. +const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4; /// Enrichment as currently configured. struct Enrichment { @@ -378,6 +398,7 @@ impl Engine { track_prefs: Arc::new(Mutex::new(track_prefs)), downloads: Arc::new(Mutex::new(Downloads::default())), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), + picture_shown: Arc::new(AtomicBool::new(false)), events, ctx, }; @@ -441,8 +462,43 @@ impl Engine { /// Send an event and wake the UI. A closed channel means the window is /// gone, which is not an error worth propagating anywhere. pub(crate) fn emit(&self, event: Event) { + let ticking = matches!( + event, + Event::Player { + event: pstr_player::PlayerEvent::Position(_), + .. + } + ); if self.events.send(event).is_ok() { + if ticking { + self.wake(); + } else { + self.ctx.request_repaint(); + } + } + } + + /// Say whether the player page is the one on screen. + pub fn set_picture_shown(&self, shown: bool) { + self.picture_shown.store(shown, Ordering::Relaxed); + } + + /// Ask for a frame because playback moved on. + /// + /// mpv reports the position and offers a new picture once per video frame. + /// On the player page that is exactly the rate to draw at. Anywhere else + /// the film is a line of text and a bar in the transport, and redrawing a + /// whole library page at the film's frame rate to move them is most of the + /// app's CPU while something plays in the background. Four times a second + /// keeps the clock honest. + pub fn wake(&self) { + /// How often the transport bar moves while the picture is hidden. + const BACKGROUND_TICK: std::time::Duration = std::time::Duration::from_millis(250); + + if self.picture_shown.load(Ordering::Relaxed) { self.ctx.request_repaint(); + } else { + self.ctx.request_repaint_after(BACKGROUND_TICK); } } @@ -468,9 +524,9 @@ impl Engine { self.runtime.spawn(async move { let share = match engine.store.add(&name, &url, password.as_deref()) { Ok(share) => share, - Err(error) => return engine.fail("add that share", error), + Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), }; - engine.emit(Event::Status(format!("added {}", share.name))); + engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); engine.connect_and_crawl(Some(share.id)).await; }); @@ -677,6 +733,7 @@ impl Engine { state: DownloadState::Completed, downloaded: expected, total: expected, + rate: 0.0, }); } } else { @@ -726,7 +783,30 @@ impl Engine { for share_id in targets { let started = std::time::Instant::now(); - let nodes = match library.crawl(&share_id).await { + let name = self + .store + .list() + .ok() + .and_then(|shares| shares.into_iter().find(|share| share.id == share_id)) + .map_or_else(|| share_id.clone(), |share| share.name); + self.emit(Event::CrawlProgress { + share: name.clone(), + found: 0, + }); + // At most a few times a second: a wide level comes back as a burst + // of listings, and a frame per listing is a frame per folder. + let last = parking_lot::Mutex::new(std::time::Instant::now()); + let report = |found: usize| { + let mut last = last.lock(); + if last.elapsed() >= std::time::Duration::from_millis(250) { + *last = std::time::Instant::now(); + self.emit(Event::CrawlProgress { + share: name.clone(), + found, + }); + } + }; + let nodes = match library.crawl_reporting(&share_id, report).await { Ok(nodes) => nodes, Err(error) => { self.fail(&format!("crawl {share_id}"), error); @@ -903,15 +983,53 @@ impl Engine { Ok(stream) if stream.size() == 0 => { engine.emit(Event::Error(format!("{} has no content", target.name))); } - Ok(stream) => engine.emit(Event::PlaybackReady { - target: Box::new(target), - stream, - }), + Ok(stream) => { + // Before mpv is even built: building it takes the UI thread + // a moment, and the network can spend that moment on the + // two reads every file opens with. + stream.warm_edges(); + engine.emit(Event::PlaybackReady { + target: Box::new(target), + stream, + }) + } Err(error) => engine.fail(&format!("open {}", target.name), error), } }); } + /// Fetch the blocks a file opens with into the cache, without playing it. + /// + /// For the episode after the one playing, so that autoplay — or a click on + /// "Play now" — starts from the cache rather than from two round trips. A + /// local copy needs none of this, and a failure here costs nothing but the + /// head start, so both are quietly skipped. + pub fn warm(&self, target: &PlaybackTarget) { + let engine = self.clone(); + let share_id = target.share_id.clone(); + let link_id = target.link_id.clone(); + let uid = node_uid(&target.volume_id, &target.link_id); + self.runtime.spawn(async move { + let offline = engine + .catalog + .lock() + .offline_file(&share_id, &link_id) + .ok() + .flatten() + .is_some(); + if offline { + return; + } + let Some(source) = engine.opened().map(|(_, source)| source) else { + return; + }; + match source.open(&share_id, &uid).await { + Ok(stream) => stream.warm_edges(), + Err(error) => tracing::debug!(%error, "warming the next episode failed"), + } + }); + } + /// Queue complete plaintext copies. Jobs for the same episode are /// de-duplicated; a failed or cancelled row is a resumable retry. pub fn make_offline(&self, targets: Vec) { @@ -946,6 +1064,7 @@ impl Engine { state: DownloadState::Queued, downloaded: previous.as_ref().map_or(0, |item| item.downloaded), total: previous.as_ref().map_or(0, |item| item.total), + rate: 0.0, }, ); } @@ -1191,6 +1310,7 @@ impl Engine { state: DownloadState::Cancelled, downloaded, total, + rate: 0.0, }); } let mut downloads = self.downloads.lock(); @@ -1229,7 +1349,7 @@ impl Engine { anyhow::bail!("share is not connected"); }; let stream = source - .open( + .open_for_copy( &target.share_id, &node_uid(&target.volume_id, &target.link_id), ) @@ -1298,14 +1418,27 @@ impl Engine { output.seek(std::io::SeekFrom::Start(offset)).await?; self.update_download(&key, |item| item.downloaded = offset); + use futures::TryStreamExt as _; + let mut fetches = None; let mut last_snapshot = std::time::Instant::now(); + let mut snapshot_bytes = offset; while block_index < block_sizes.len() { + // Dropped before a pause rather than held across it: a fetch left + // half-done for as long as the viewer stays paused comes back as a + // timed-out request, and a timed-out request fails the download. + if matches!(*command.borrow(), DownloadCommand::Pause) { + fetches = None; + } if !wait_until_runnable(&mut command).await { output.sync_all().await?; return Ok(DownloadEnd::Cancelled); } let size = block_sizes[block_index]; - let bytes = stream.read_range(offset, size).await?; + let pending = + fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index)); + let Some(bytes) = pending.try_next().await? else { + anyhow::bail!("block {block_index} never arrived"); + }; if bytes.len() as u64 != size { anyhow::bail!( "short block {block_index}: received {}, expected {size}", @@ -1322,11 +1455,21 @@ impl Engine { if last_snapshot.elapsed() >= std::time::Duration::from_millis(100) || block_index == block_sizes.len() { + let seconds = last_snapshot.elapsed().as_secs_f64(); + let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON); self.update_download(&key, |item| { item.state = DownloadState::Running; item.downloaded = offset; + // Smoothed, or the time left jumps about with every block + // that took a little longer than the last. + item.rate = if item.rate > 0.0 { + 0.8 * item.rate + 0.2 * instant + } else { + instant + }; }); last_snapshot = std::time::Instant::now(); + snapshot_bytes = offset; } } output.sync_all().await?; @@ -1891,6 +2034,35 @@ fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) { (block_sizes.len(), offset) } +/// A file's blocks from `first` on, fetched several at a time and yielded in +/// file order — which is what the resume marker's "the length is the +/// boundary" rule needs. One at a time, a download runs at one block per round +/// trip whatever the link can carry. +fn fetch_in_order( + stream: &VideoStream, + block_sizes: &[u64], + first: usize, +) -> futures::stream::BoxStream<'static, pstr_stream::Result>> { + use futures::StreamExt as _; + let start: u64 = block_sizes[..first].iter().sum(); + let ranges: Vec<(u64, u64)> = block_sizes[first..] + .iter() + .scan(start, |offset, size| { + let range = (*offset, *size); + *offset += size; + Some(range) + }) + .collect(); + let stream = stream.clone(); + futures::stream::iter(ranges) + .map(move |(offset, size)| { + let stream = stream.clone(); + async move { stream.read_range(offset, size).await } + }) + .buffered(DOWNLOAD_BLOCKS_IN_FLIGHT) + .boxed() +} + async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver) -> bool { loop { let current = *command.borrow_and_update(); @@ -2008,11 +2180,15 @@ impl ImageCache { /// /// `None` while a fetch is in flight, and forever for a picture that turned /// out not to exist — the negative answer is remembered deliberately. - pub fn texture(&mut self, key: String, fetch: impl FnOnce()) -> Option { - if let Some(texture) = self.textures.get(&key) { + /// + /// Borrows the key: this is asked once per tile per frame, and a hit — the + /// usual answer — should not cost an allocation. + pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option { + if let Some(texture) = self.textures.get(key) { return Some(texture.clone()); } - if !self.missing.contains(&key) && self.requested.insert(key) { + if !self.missing.contains(key) && !self.requested.contains(key) { + self.requested.insert(key.to_owned()); fetch(); } None @@ -2027,6 +2203,13 @@ impl ImageCache { self.missing.insert(key); } + /// Forget one picture, so the next ask fetches it again. + pub fn forget(&mut self, key: &str) { + self.textures.remove(key); + self.requested.remove(key); + self.missing.remove(key); + } + /// Forget everything. Used when the catalog is replaced, so a recrawl that /// changed link ids does not leave stale pictures behind, and when the /// metadata provider changes, so the old provider's artwork goes with it. diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 2437d3e..8b8d419 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -134,6 +134,11 @@ pub enum Command { /// every file it loads afterwards, so picking Japanese audio for one /// episode picks it for the next one too. SelectTrack(TrackKind, Option), + /// Move subtitles, as a percentage of the window's height. See + /// [`Playback::lift_subtitles`]. + SetSubtitlePosition(f64), + /// 1.0 is normal speed. + SetSpeed(f64), Stop, } @@ -162,6 +167,11 @@ pub struct Playback { /// 0–100, as mpv has it. pub volume: f64, pub muted: bool, + /// Playback speed, 1.0 being normal. + pub speed: f64, + /// Where subtitles were last put, so they are only moved when that changes + /// rather than once a frame. + subtitle_position: f64, /// What the file contains. Empty until mpv has demuxed it. pub tracks: Vec, /// The file's chapters, in order. Empty for a file muxed without them. @@ -196,10 +206,9 @@ impl Playback { target: PlaybackTarget, stream: VideoStream, gl: Option<&Arc>, - ctx: &egui::Context, ) -> Result { let prefs = engine.playback_prefs(); - let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?; + let (player, video) = build_player(engine, &target, &prefs, gl)?; let (commands, orders) = channel::(); let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); @@ -234,6 +243,8 @@ impl Playback { paused: false, volume: prefs.volume, muted: prefs.muted, + subtitle_position: 100.0, + speed: prefs.speed, tracks: Vec::new(), chapters: Vec::new(), roles: Vec::new(), @@ -246,6 +257,24 @@ impl Playback { }) } + /// Keep subtitles clear of `covered` points at the bottom of a picture + /// `height` points tall — the controls, while they are up. + /// + /// Without it the line being read sits under the seek bar for as long as + /// the mouse is moving, which is exactly when someone is looking at the + /// screen. + pub fn lift_subtitles(&mut self, covered: f32, height: f32) { + let position = if covered > 0.0 && height > 0.0 { + (100.0 * (1.0 - f64::from(covered / height))).round() + } else { + 100.0 + }; + if position != self.subtitle_position { + self.subtitle_position = position; + self.send(Command::SetSubtitlePosition(position)); + } + } + /// Whether the picture is drawn in this window rather than mpv's own. pub fn is_embedded(&self) -> bool { self.video.is_some() @@ -365,7 +394,6 @@ fn build_player( target: &PlaybackTarget, prefs: &PlaybackPrefs, gl: Option<&Arc>, - ctx: &egui::Context, ) -> Result<(Arc, Option), String> { let title = format!("proton-stream — {}", target.name); @@ -386,8 +414,12 @@ fn build_player( // SAFETY: called from `App::ui`, where eframe has made this // context current, and the surface is dropped from the same // place — see `video.rs`. - match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) } - { + match unsafe { + VideoSurface::new(Arc::clone(&player), Arc::clone(gl), { + let engine = engine.clone(); + move || engine.wake() + }) + } { Ok(video) => return Ok((player, Some(video))), Err(error) => { tracing::warn!("embed the video: {error}; falling back to a window"); @@ -428,6 +460,9 @@ fn from_prefs( .and_then(|p| p.subtitle_language.clone()) .or_else(|| prefs.subtitle_language.clone()), subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), + // Carried from file to file, like the volume: somebody who watches at + // 1.25 watches the next episode at 1.25 too. + options: vec![("speed".into(), format!("{:.2}", prefs.speed))], ..PlayerConfig::default() } } @@ -569,6 +604,8 @@ fn apply_command( Command::SeekTo(seconds) => player.seek_to(seconds), Command::SetVolume(volume) => player.set_volume(volume), Command::SetMuted(muted) => player.set_muted(muted), + Command::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), + Command::SetSpeed(speed) => player.set_speed(speed), Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track), Command::Stop => { let _ = player.quit(); diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 3d67e41..2da983e 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -358,6 +358,36 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett painter.add(textured(rect, corner_radius, &texture)); } +/// A rounded rectangle running from one colour to another, at `opacity`. +/// +/// For colours that are not the palette's — a title's own, on a tile with no +/// picture. Drawn flat when the viewer has ramps off, like every other ramp. +pub fn ramp_fill( + painter: &egui::Painter, + rect: Rect, + corner_radius: CornerRadius, + (from, to): (Color32, Color32), + direction: Direction, + opacity: f32, +) { + let tint = Color32::WHITE.gamma_multiply(opacity); + if !ramps_on() { + painter.rect_filled( + rect, + corner_radius, + mix(from, to, 0.5).gamma_multiply(opacity), + ); + return; + } + let texture = ramp(painter.ctx(), from, to, direction); + painter.add( + egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture( + texture.id(), + Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + ), + ); +} + /// A ramp from `color` to nothing, for laying artwork into the page. /// /// The other ramps here run between two opaque colours; this one runs down its diff --git a/crates/pstr-app/src/ui/downloads.rs b/crates/pstr-app/src/ui/downloads.rs index c14b837..4f444de 100644 --- a/crates/pstr-app/src/ui/downloads.rs +++ b/crates/pstr-app/src/ui/downloads.rs @@ -5,62 +5,112 @@ use crate::engine::{DownloadItem, DownloadState}; use crate::{theme, ui}; pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec) { - ui.heading("Downloads"); - ui.label(ui::muted( - "Pause and Cancel keep complete downloaded blocks. Delete partial discards them.", - )); - ui.add_space(12.0); - if downloads.is_empty() { - ui.label("No offline downloads."); + ui::empty_state( + ui, + "No downloads", + "Download an episode, a season or a whole show from its page, and it plays \ + without a connection.", + ); return; } - let mut start = 0; - while start < downloads.len() { - let title_key = &downloads[start].target.title_key; - let title = &downloads[start].target.title_name; - let end = downloads[start..] - .iter() - .position(|item| item.target.title_key != *title_key) - .map_or(downloads.len(), |offset| start + offset); - let group = &downloads[start..end]; - let downloaded: u64 = group.iter().map(|item| item.downloaded).sum(); - let total: u64 = group.iter().map(|item| item.total).sum(); - let completed = group - .iter() - .filter(|item| item.state == DownloadState::Completed) - .count(); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_max_width(960.0); + summary(ui, downloads); + ui.add_space(12.0); - ui.label(theme::Role::Section.rich(title).strong()); - let totals_known = group.iter().all(|item| item.total > 0); - ui.horizontal(|ui| { - if totals_known { - ui.add( - egui::ProgressBar::new(fraction(downloaded, total)) - .desired_width(260.0) - .show_percentage(), - ); - } else { - ui.add(egui::Spinner::new().size(16.0)); - ui.label(ui::muted("Calculating size…")); + let mut start = 0; + while start < downloads.len() { + let title_key = &downloads[start].target.title_key; + let end = downloads[start..] + .iter() + .position(|item| item.target.title_key != *title_key) + .map_or(downloads.len(), |offset| start + offset); + group(ui, &downloads[start..end], actions); + ui.add_space(14.0); + start = end; } - ui.label(ui::muted(format!( - "{} / {} · {completed}/{} files", - bytes(downloaded), - if !totals_known { - "calculating…".to_owned() - } else { - bytes(total) - }, - group.len() - ))); }); - for item in group { - row(ui, item, actions); +} + +/// The line under the heading: how much, how fast, how long. +fn summary(ui: &mut egui::Ui, downloads: &[DownloadItem]) { + ui::section(ui, "Downloads"); + let running: Vec<_> = downloads + .iter() + .filter(|item| item.state == DownloadState::Running) + .collect(); + let rate: f64 = running.iter().map(|item| item.rate).sum(); + let left: u64 = downloads + .iter() + .filter(|item| { + matches!( + item.state, + DownloadState::Queued | DownloadState::Running | DownloadState::Paused + ) + }) + .map(|item| item.total.saturating_sub(item.downloaded)) + .sum(); + let mut parts = vec![format!( + "{} available offline", + ui::library::plural( + downloads + .iter() + .filter(|item| item.state == DownloadState::Completed) + .count(), + "file" + ) + )]; + if !running.is_empty() { + parts.push(format!("{}/s", size(rate as u64))); + } + if left > 0 { + parts.push(format!("{} to go", size(left))); + if rate > 0.0 { + parts.push(format!("about {}", ui::format_time(left as f64 / rate))); } - ui.add_space(12.0); - start = end; + } + ui.label(ui::muted(parts.join(" · "))); + ui.label(ui::muted( + "Pausing or cancelling keeps every block already downloaded.", + )); +} + +/// One title's downloads, under a bar for all of them together. +fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec) { + let title = &group[0].target.title_name; + let downloaded: u64 = group.iter().map(|item| item.downloaded).sum(); + let total: u64 = group.iter().map(|item| item.total).sum(); + let completed = group + .iter() + .filter(|item| item.state == DownloadState::Completed) + .count(); + let totals_known = group.iter().all(|item| item.total > 0); + + ui.horizontal(|ui| { + ui.label(theme::Role::Subhead.rich(title).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(ui::muted(if totals_known { + format!( + "{completed} of {} · {} of {}", + group.len(), + size(downloaded), + size(total) + ) + } else { + format!("{completed} of {} · working out the size…", group.len()) + })); + }); + }); + if totals_known && group.len() > 1 { + ui::progress_bar(ui, fraction(downloaded, total), ui.available_width()); + } + ui.add_space(6.0); + for item in group { + row(ui, item, actions); } } @@ -68,7 +118,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { egui::Frame::new() .fill(theme::card()) .corner_radius(egui::CornerRadius::same(6)) - .inner_margin(egui::Margin::symmetric(10, 8)) + .inner_margin(egui::Margin::symmetric(12, 8)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { @@ -78,85 +128,109 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { item.target.subtitle.as_str() }; ui.add_sized( - [180.0, 18.0], - egui::Label::new(egui::RichText::new(caption).strong()).truncate(), + [72.0, 18.0], + egui::Label::new(egui::RichText::new(caption).monospace()).truncate(), ) .on_hover_text(&item.target.name); - let status = match &item.state { - DownloadState::Queued => "Queued".to_owned(), - DownloadState::Running => "Downloading".to_owned(), - DownloadState::Paused => "Paused".to_owned(), - DownloadState::Completed => "Available offline".to_owned(), - DownloadState::Cancelled => "Cancelled · partial kept".to_owned(), - DownloadState::Failed(error) => format!("Failed: {error}"), - }; + let (status, colour) = status(item); ui.add_sized( - [240.0, 18.0], - egui::Label::new(ui::muted(status)).truncate(), + [220.0, 18.0], + egui::Label::new(theme::Role::Caption.rich(status).color(colour)).truncate(), ); - if item.total > 0 { - ui.add( - egui::ProgressBar::new(item.percent()) - .desired_width(140.0) - .show_percentage(), - ); - ui.label(ui::muted(format!( - "{} / {}", - bytes(item.downloaded), - bytes(item.total) - ))); - } - - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| match item.state { - DownloadState::Queued | DownloadState::Running => { - if ui - .button("Cancel") - .on_hover_text("Stop and keep partial") - .clicked() - { - actions.push(Action::CancelDownload(item.key.clone())); - } - if ui.button("Pause").clicked() { - actions.push(Action::PauseDownload(item.key.clone())); - } - } - DownloadState::Paused => { - if ui - .button("Cancel") - .on_hover_text("Stop and keep partial") - .clicked() - { - actions.push(Action::CancelDownload(item.key.clone())); - } - if ui.button("Resume").clicked() { - actions.push(Action::ResumeDownload(item.key.clone())); - } - } - DownloadState::Cancelled | DownloadState::Failed(_) => { - if ui.button("Delete partial…").clicked() { - actions.push(Action::RemoveDownload(item.key.clone(), true)); - } - if ui.button("Resume / Retry").clicked() { - actions.push(Action::ResumeDownload(item.key.clone())); - } - } - DownloadState::Completed => { - if ui - .button("Delete") - .on_hover_text("Delete the offline copy; keep the online source") - .clicked() - { - actions.push(Action::RemoveDownload(item.key.clone(), false)); - } - } - }, - ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + buttons(ui, item, actions); + if item.total > 0 && item.state != DownloadState::Completed { + ui.add_space(8.0); + ui.label(ui::muted(format!( + "{} / {}", + size(item.downloaded), + size(item.total) + ))); + let width = (ui.available_width() - 8.0).clamp(40.0, 320.0); + ui::progress_bar(ui, item.percent(), width); + } + }); }); }); + ui.add_space(4.0); +} + +/// What a row says about itself, and in what colour. +fn status(item: &DownloadItem) -> (String, egui::Color32) { + match &item.state { + DownloadState::Queued => ("Waiting".to_owned(), theme::muted()), + DownloadState::Running => { + let mut text = format!("{}/s", size(item.rate as u64)); + if let Some(eta) = item.eta() { + text.push_str(&format!(" · {} left", ui::format_time(eta))); + } + (text, theme::text()) + } + DownloadState::Paused => ("Paused".to_owned(), theme::muted()), + DownloadState::Completed => ("Available offline".to_owned(), theme::muted()), + DownloadState::Cancelled => ("Stopped · progress kept".to_owned(), theme::muted()), + DownloadState::Failed(error) => (format!("Failed: {error}"), theme::danger()), + } +} + +fn buttons(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { + let key = || item.key.clone(); + match item.state { + DownloadState::Queued | DownloadState::Running => { + if ui + .button("Cancel") + .on_hover_text("Stop, and keep what has downloaded") + .clicked() + { + actions.push(Action::CancelDownload(key())); + } + if ui.button("Pause").clicked() { + actions.push(Action::PauseDownload(key())); + } + } + DownloadState::Paused => { + if ui + .button("Cancel") + .on_hover_text("Stop, and keep what has downloaded") + .clicked() + { + actions.push(Action::CancelDownload(key())); + } + if ui.button("Resume").clicked() { + actions.push(Action::ResumeDownload(key())); + } + } + DownloadState::Cancelled | DownloadState::Failed(_) => { + if ui + .button("Delete") + .on_hover_text("Discard what has downloaded so far") + .clicked() + { + actions.push(Action::RemoveDownload(key(), true)); + } + if ui + .button(if matches!(item.state, DownloadState::Failed(_)) { + "Retry" + } else { + "Resume" + }) + .clicked() + { + actions.push(Action::ResumeDownload(key())); + } + } + DownloadState::Completed => { + if ui + .button("Delete") + .on_hover_text("Delete the offline copy; keep the online source") + .clicked() + { + actions.push(Action::RemoveDownload(key(), false)); + } + } + } } fn fraction(downloaded: u64, total: u64) -> f32 { @@ -167,18 +241,6 @@ fn fraction(downloaded: u64, total: u64) -> f32 { } } -fn bytes(value: u64) -> String { - const KIB: f64 = 1024.0; - const MIB: f64 = 1024.0 * KIB; - const GIB: f64 = 1024.0 * MIB; - let value = value as f64; - if value >= GIB { - format!("{:.1} GiB", value / GIB) - } else if value >= MIB { - format!("{:.1} MiB", value / MIB) - } else if value >= KIB { - format!("{:.1} KiB", value / KIB) - } else { - format!("{value:.0} B") - } +fn size(bytes: u64) -> String { + ui::format_size(i64::try_from(bytes).unwrap_or(i64::MAX)) } diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 6aee37e..140870a 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -2,30 +2,49 @@ use pstr_core::library::{Library, Title, TitleKind}; -use crate::app::{Action, Page}; +use crate::app::{Action, Filter, LibraryView, Page, Sort}; use crate::theme; use crate::ui::Art; use crate::ui::{self, Card}; -pub fn show( - ui: &mut egui::Ui, - art: &mut Art<'_>, - library: &Library, - search: &str, - actions: &mut Vec, -) { +/// What the page lists, and what it was asked for. +pub struct Shelves<'a> { + pub library: &'a Library, + /// The search and the shelf, already worked out — see [`LibraryView`]. + pub view: &'a LibraryView, + /// Whether the catalog has been read yet. + pub loaded: bool, + pub search: &'a str, +} + +pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: &mut Vec) { + let Shelves { + library, + view, + loaded, + search, + } = shelves; + if !loaded { + return placeholder(ui); + } if library.is_empty() { return empty_state(ui, actions); } - let matches = library.search(search); + let pick = |indices: &[usize]| -> Vec<&Title> { + indices + .iter() + .filter_map(|&index| library.titles.get(index)) + .collect() + }; + let matches = pick(&view.matches); let searching = !search.trim().is_empty(); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { if !searching { - let resumable = library.continue_watching(); + let resumable = pick(&view.resumable); if !resumable.is_empty() { ui::section(ui, "Continue watching"); continue_row(ui, art, &resumable, actions); @@ -33,22 +52,32 @@ pub fn show( } } - ui::section( - ui, - &if searching { - format!( - "{} matching {:?}", - plural(matches.len(), "title"), - search.trim() - ) - } else { - plural(matches.len(), "title") - }, - ); + ui.horizontal(|ui| { + ui::section( + ui, + &if searching { + format!( + "{} matching {:?}", + plural(matches.len(), "title"), + search.trim() + ) + } else { + plural(matches.len(), "title") + }, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + shelf_controls(ui, view, actions); + }); + }); + ui.add_space(6.0); if matches.is_empty() { ui.add_space(8.0); - ui.label(ui::muted("Nothing here by that name.")); + ui.label(ui::muted(if searching { + "Nothing here by that name." + } else { + "Nothing in the library fits that filter." + })); return; } @@ -56,6 +85,48 @@ pub fn show( }); } +/// The filter pills and the order, at the right of the grid's heading. +fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec) { + // Right to left: the order first, so it ends up last. + let sort = egui::ComboBox::from_id_salt("library-sort") + .selected_text(match view.sort { + Sort::Name => "A – Z", + Sort::Recent => "Recently watched", + Sort::Year => "Newest", + }) + .width(150.0) + .show_ui(ui, |ui| { + let mut picked = None; + for (sort, label) in [ + (Sort::Name, "A – Z"), + (Sort::Recent, "Recently watched"), + (Sort::Year, "Newest"), + ] { + if ui.selectable_label(view.sort == sort, label).clicked() { + picked = Some(sort); + } + } + picked + }); + if let Some(Some(sort)) = sort.inner { + actions.push(Action::SetShelf(view.filter, sort)); + } + ui.add_space(10.0); + if let Some(filter) = ui::widgets::segmented( + ui, + view.filter, + &[ + (Filter::All, "All"), + (Filter::Series, "Series"), + (Filter::Films, "Films"), + (Filter::Watching, "Watching"), + (Filter::Unwatched, "Not started"), + ], + ) { + actions.push(Action::SetShelf(filter, view.sort)); + } +} + /// The top shelf: one card per part-watched title, scrolling sideways. fn continue_row( ui: &mut egui::Ui, @@ -111,7 +182,22 @@ fn continue_row( /// Every title, wrapped to the window. fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec) { let grid = ui::columns(ui.available_width()); + let row_height = ui::card_height(grid.width); for row in titles.chunks(grid.columns) { + // A row scrolled out of view is only its height. Laying it out anyway + // costs a subtitle and an artwork lookup per card per frame, and while + // a film plays under the transport bar a frame is every frame. + let slot = egui::Rect::from_min_size( + ui.cursor().min, + egui::vec2(ui.available_width(), row_height), + ); + if !ui.is_rect_visible(slot) { + // The spacing `horizontal` would have added after itself, too, or + // the page grows and shrinks under the scroll bar as rows come and + // go. + ui.add_space(row_height + ui.spacing().item_spacing.y + theme::CARD_GAP); + continue; + } ui.horizontal(|ui| { // The gap the width was divided around, so what is drawn matches // what was measured. egui's default item spacing is narrower, and @@ -168,6 +254,34 @@ fn subtitle(title: &Title) -> String { } } +/// The page before the catalog has been read: the shape of a grid, with +/// nothing in it yet. +/// +/// Not the empty state. The catalog is on disk and is read in a fraction of a +/// second, and telling every launch "Nothing in the library yet" for that +/// fraction is a flash of something false. +fn placeholder(ui: &mut egui::Ui) { + let grid = ui::columns(ui.available_width()); + let height = ui::card_height(grid.width); + ui::section(ui, "Library"); + for _ in 0..2 { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; + for _ in 0..grid.columns { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(grid.width, height), egui::Sense::hover()); + let image = egui::Rect::from_min_size( + rect.min, + egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()), + ); + ui.painter() + .rect_filled(image, egui::CornerRadius::same(8), theme::card()); + } + }); + ui.add_space(theme::CARD_GAP); + } +} + fn empty_state(ui: &mut egui::Ui, actions: &mut Vec) { ui.vertical_centered(|ui| { ui.add_space(120.0); diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs index 2518a1e..19c5b77 100644 --- a/crates/pstr-app/src/ui/matcher.rs +++ b/crates/pstr-app/src/ui/matcher.rs @@ -258,9 +258,8 @@ fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option { .get(episode.node.parsed.season, number) } + /// A still for one episode: the provider's, else Proton's own thumbnail of + /// the file. Only asked for by rows that are on screen. + pub fn still(&mut self, title_key: &str, episode: &Episode) -> Option { + let url = self + .episode(title_key, episode) + .and_then(|found| found.still_url.clone()); + if let Some(url) = url { + let key = format!("still:{}", url); + let engine = self.engine; + if let Some(texture) = self + .posters + .texture(&key, || engine.request_poster(key.clone(), url)) + { + return Some(texture); + } + } + let node = &episode.node; + let engine = self.engine; + self.thumbs + .texture(&thumbnail_key(node), || engine.request_thumbnail(node)) + } + /// The picture for a title, and how to fit it. /// /// Provider artwork first, then Proton's still, then nothing. That order is @@ -62,12 +87,9 @@ impl Art<'_> { .and_then(|metadata| metadata.tile_art()) { let engine = self.engine; - let key = title.key.clone(); - let url = url.to_string(); - if let Some(texture) = self - .posters - .texture(title.key.clone(), || engine.request_poster(key, url)) - { + if let Some(texture) = self.posters.texture(&title.key, || { + engine.request_poster(title.key.clone(), url.to_string()) + }) { return Some((texture, shape)); } } @@ -76,7 +98,7 @@ impl Art<'_> { let engine = self.engine; let texture = self .thumbs - .texture(thumbnail_key(node), || engine.request_thumbnail(node))?; + .texture(&thumbnail_key(node), || engine.request_thumbnail(node))?; Some((texture, ArtShape::Landscape)) } } @@ -95,6 +117,12 @@ pub fn format_time(seconds: f64) -> String { } } +/// `1.25×`, or `1×`. +pub fn format_speed(speed: f64) -> String { + let text = format!("{speed:.2}"); + format!("{}×", text.trim_end_matches('0').trim_end_matches('.')) +} + /// `1.4 GiB`, for what a file costs to watch. pub fn format_size(bytes: i64) -> String { const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"]; @@ -133,6 +161,184 @@ pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response { filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0)) } +/// The search box in the top bar: a magnifier, the text, and a way to clear it. +/// +/// Escape inside it clears it. `focus` puts the caret in it this frame — which +/// is what `Ctrl+F` and `/` ask for from anywhere in the library. +pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { + let id = egui::Id::new("library-search"); + let width = 240.0; + let height = ui.spacing().interact_size.y; + let (outer, _) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover()); + let has_focus = ui.memory(|memory| memory.has_focus(id)); + let lit = ui + .ctx() + .animate_bool_with_time(id.with("lit"), has_focus, 0.12); + + let radius = CornerRadius::same((height / 2.0) as u8); + ui.painter().rect_filled(outer, radius, theme::card()); + ui.painter().rect_stroke( + outer, + radius, + Stroke::new(1.0, theme::card_hover().lerp_to_gamma(theme::accent(), lit)), + egui::StrokeKind::Inside, + ); + + // A magnifier: a ring and a handle, so no font has to have the glyph. + let ink = theme::muted().lerp_to_gamma(theme::text(), lit); + let lens = egui::pos2(outer.left() + 16.0, outer.center().y - 1.0); + ui.painter().circle_stroke(lens, 5.0, Stroke::new(1.5, ink)); + ui.painter().line_segment( + [lens + Vec2::new(3.6, 3.6), lens + Vec2::new(7.0, 7.0)], + Stroke::new(1.5, ink), + ); + + let clear_width = if search.is_empty() { 0.0 } else { 26.0 }; + let field_rect = Rect::from_min_max( + egui::pos2(outer.left() + 28.0, outer.top()), + egui::pos2(outer.right() - 8.0 - clear_width, outer.bottom()), + ); + let response = ui.put( + field_rect, + egui::TextEdit::singleline(search) + .id(id) + .frame(egui::Frame::NONE) + .hint_text(if has_focus { + "Titles and filenames" + } else { + "Search Ctrl+F" + }) + .vertical_align(egui::Align::Center) + .desired_width(field_rect.width()), + ); + if focus { + response.request_focus(); + } + if response.has_focus() && ui.input(|input| input.key_pressed(egui::Key::Escape)) { + search.clear(); + response.surrender_focus(); + } + + if !search.is_empty() { + let clear = Rect::from_center_size( + egui::pos2(outer.right() - 18.0, outer.center().y), + Vec2::splat(18.0), + ); + let response = ui.interact(clear, id.with("clear"), Sense::click()); + let ink = if response.hovered() { + theme::text() + } else { + theme::muted() + }; + let c = clear.center(); + let d = 3.5; + ui.painter().line_segment( + [c + Vec2::new(-d, -d), c + Vec2::new(d, d)], + Stroke::new(1.5, ink), + ); + ui.painter().line_segment( + [c + Vec2::new(-d, d), c + Vec2::new(d, -d)], + Stroke::new(1.5, ink), + ); + if response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("Clear (Esc)") + .clicked() + { + search.clear(); + } + } +} + +/// A thin bar in the accent, for how far something has got. +/// +/// egui's own progress bar is one flat colour with the percentage written over +/// it; this is the same bar the tiles and the seek bar use, so a download reads +/// as part of the same app. +pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover()); + if ui.is_rect_visible(rect) { + let radius = CornerRadius::same(3); + ui.painter().rect_filled(rect, radius, theme::card_hover()); + let mut done = rect; + done.set_width(rect.width() * fraction.clamp(0.0, 1.0)); + if done.width() > 0.5 { + theme::accent_fill(ui.painter(), done, radius, 0.0); + } + } + response +} + +/// A centred message for a page with nothing on it: what is missing, and what +/// to do about it. +pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) { + ui.vertical_centered(|ui| { + ui.add_space(96.0); + ui.label(theme::Role::Title.rich(heading).strong()); + ui.add_space(6.0); + ui.label(muted(body)); + }); +} + +/// The danger colour as a button: for the one choice in a dialog that cannot be +/// taken back. +pub fn danger_button(ui: &mut egui::Ui, text: &str) -> egui::Response { + ui.add( + egui::Button::new(egui::RichText::new(text).color(theme::on_accent())) + .fill(theme::danger()), + ) +} + +/// What a viewer answered a [`confirm`] dialog with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Answer { + /// Still open. + Pending, + Confirmed, + /// Cancelled — by the button, Escape, or a click beside the dialog. + Declined, +} + +/// A question with two answers, over everything else. +/// +/// For the actions that destroy something the viewer cannot get back from +/// inside the app — stored secrets, downloaded bytes. Everything else acts on +/// the click. +pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer { + let mut answer = Answer::Pending; + let modal = egui::Modal::new(egui::Id::new(("confirm", id))) + .frame( + egui::Frame::new() + .fill(theme::surface()) + .inner_margin(egui::Margin::same(18)) + .corner_radius(CornerRadius::same(10)), + ) + .show(ctx, |ui| { + ui.set_width(420.0); + ui.label( + theme::Role::Heading + .rich(heading) + .strong() + .color(theme::text()), + ); + ui.add_space(6.0); + ui.label(theme::Role::Body.rich(body).color(theme::muted())); + ui.add_space(16.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if danger_button(ui, verb).clicked() { + answer = Answer::Confirmed; + } + if ui.button("Cancel").clicked() { + answer = Answer::Declined; + } + }); + }); + if answer == Answer::Pending && modal.should_close() { + answer = Answer::Declined; + } + answer +} + /// What [`filled`] paints behind its label. #[derive(Clone, Copy, PartialEq, Eq)] enum Fill { @@ -281,6 +487,17 @@ pub struct Card<'a> { pub width: f32, } +/// Two lines of name plus one of subtitle. Fixed, because the tile is allocated +/// before the name is laid out — and because a grid whose rows are each a +/// different height by title length reads as broken. +const CARD_TEXT_HEIGHT: f32 = 54.0; + +/// How tall [`card`] draws a tile of this width, so a grid can step over rows +/// it does not draw. +pub fn card_height(width: f32) -> f32 { + (width * theme::CARD_ASPECT).round() + CARD_TEXT_HEIGHT +} + /// Draw one tile and report whether it was clicked. /// /// A landscape picture is cropped to fill: a grid of differently-shaped black @@ -292,10 +509,7 @@ pub struct Card<'a> { pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { let width = card.width; let image_height = (width * theme::CARD_ASPECT).round(); - // Two lines of name plus one of subtitle. Fixed, because the tile is - // allocated before the name is laid out — and because a grid whose rows are - // each a different height by title length reads as broken. - let text_height = 54.0; + let text_height = CARD_TEXT_HEIGHT; let radius = CornerRadius::same(8); let (rect, response) = @@ -328,13 +542,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { // Under the picture, not instead of it: it is what shows through while the // art fades in, and it is the whole tile when there is no art to come. if art_in < 1.0 { - painter.text( - image_rect.center(), - Align2::CENTER_CENTER, - initials(card.name), - egui::FontId::proportional(28.0), - theme::muted().gamma_multiply(1.0 - art_in), - ); + placeholder(painter, image_rect, card.name, 1.0 - art_in); } // White because a textured rect *multiplies* its fill by the texture; the @@ -470,7 +678,7 @@ fn contain_rect(image: Vec2, target: Rect) -> Rect { } /// UV rectangle that crops `image` to fill `target` without distorting it. -fn cover_uv(image: Vec2, target: Vec2) -> Rect { +pub fn cover_uv(image: Vec2, target: Vec2) -> Rect { if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 { return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); } @@ -485,14 +693,55 @@ fn cover_uv(image: Vec2, target: Vec2) -> Rect { Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height)) } -/// Up to two letters, for a tile with no picture. -fn initials(name: &str) -> String { - name.split_whitespace() - .filter_map(|word| word.chars().next()) - .filter(|character| character.is_alphanumeric()) - .take(2) - .flat_map(char::to_uppercase) - .collect() +/// What a tile with no picture shows: a gradient of its own, and its name. +/// +/// Two grey letters on a grey card was the whole tile for any title nothing +/// knew a picture for — a wall of them read as a page that had failed to load. +/// The colours come from the name, so a title wears the same ones every time +/// and two titles side by side rarely match. +/// +/// The hash is FNV-1a: stable across runs and platforms, which the standard +/// library's hasher deliberately is not. +fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opacity: f32) { + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }); + let hue = (hash % 360) as f32 / 360.0; + let light = theme::palette().light; + let (saturation, value) = if light { (0.25, 0.92) } else { (0.45, 0.34) }; + let from: Color32 = egui::ecolor::Hsva::new(hue, saturation, value, 1.0).into(); + let to: Color32 = + egui::ecolor::Hsva::new((hue + 0.12) % 1.0, saturation, value * 0.65, 1.0).into(); + theme::ramp_fill( + painter, + rect, + CornerRadius::same(8), + (from, to), + theme::Direction::Vertical, + opacity, + ); + + let ink = if light { + Color32::from_black_alpha(170) + } else { + Color32::from_white_alpha(215) + } + .gamma_multiply(opacity); + let mut job = egui::text::LayoutJob::simple( + name.to_owned(), + theme::Role::Heading.font(), + ink, + rect.width() - 28.0, + ); + job.wrap.max_rows = 3; + job.wrap.overflow_character = Some('…'); + job.halign = egui::Align::Center; + let galley = painter.layout_job(job); + painter.galley( + egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0), + galley, + ink, + ); } /// How a grid divides the width it was given. @@ -567,13 +816,6 @@ mod tests { ); } - #[test] - fn initials_take_two_letters() { - assert_eq!(initials("Cowboy Bebop"), "CB"); - assert_eq!(initials("Akira"), "A"); - assert_eq!(initials(""), ""); - } - #[test] fn column_count_never_reaches_zero() { assert_eq!(columns(0.0).columns, 1); diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index fd12e50..73900a8 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -123,6 +123,12 @@ pub struct UpNextCard { } impl Overlay { + /// How much of the bottom of the window the controls take, the last time + /// they were drawn — so things floating over the picture can keep clear. + pub fn covered(&self) -> f32 { + self.chrome_height + } + /// Fold this frame's pointer into the overlay's timer. pub fn observe(&mut self, ctx: &egui::Context) { let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos())); @@ -208,6 +214,11 @@ pub fn show( actions.push(Action::Player(Command::TogglePause)); } if response.double_clicked() { + // The first half of a double click already paused, and a viewer who + // double-clicked asked for fullscreen, not for a pause — so the second + // half takes the pause back. Waiting out the double-click delay before + // pausing would make every single click feel late instead. + actions.push(Action::Player(Command::TogglePause)); actions.push(Action::ToggleFullscreen); } @@ -226,16 +237,31 @@ pub fn show( None => skip(ui, playback, rect, float_above, actions), } - if visible { - overlay.chrome_height = chrome( - ui, - playback, - neighbours, - rect, - overlay.chrome_height, - actions, - ); - } else { + // Last frame's height, like everything else that has to clear the controls. + playback.lift_subtitles( + if visible { overlay.chrome_height } else { 0.0 }, + rect.height(), + ); + + // Faded rather than switched, both ways: controls that blink out under a + // reading eye are more distracting than the ones that were there. + let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, 0.18); + if fade > 0.0 { + overlay.chrome_height = ui + .scope(|ui| { + ui.multiply_opacity(fade); + chrome( + ui, + playback, + neighbours, + rect, + overlay.chrome_height, + actions, + ) + }) + .inner; + } + if !visible { ui.ctx().set_cursor_icon(egui::CursorIcon::None); // The controls have to disappear on their own, and nothing else is // going to cause a frame while a film plays without the mouse moving. @@ -610,28 +636,14 @@ fn chrome( } /// The gradient behind the title strip, fading downwards out of the top edge. -/// -/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient -/// primitive, and at this size the banding is invisible. fn scrim_top(ui: &egui::Ui, rect: Rect) { - const BANDS: usize = 10; - let painter = ui.painter(); - let band = rect.height() / BANDS as f32; - for index in 0..BANDS { - let fraction = 1.0 - index as f32 / (BANDS - 1) as f32; - let strip = Rect::from_min_size( - egui::pos2(rect.left(), rect.top() + band * index as f32), - Vec2::new(rect.width(), band + 1.0), - ); - painter.rect_filled( - strip, - CornerRadius::ZERO, - // Squared, so the fade is dark where the text is and gone well - // before the middle of the picture — a linear ramp over a strip - // this tall greys the whole top third of the frame. - Color32::from_black_alpha((fraction * fraction * 225.0) as u8), - ); - } + // Squared, so the fade is dark where the text is and gone well before the + // middle of the picture — a linear ramp over a strip this tall greys the + // whole top third of the frame. + fade(ui.painter(), rect, |t| { + let fraction = 1.0 - t; + fraction * fraction * 225.0 + }); } /// The wash behind the controls: flat over the whole strip, fading in above it. @@ -642,27 +654,81 @@ fn scrim_top(ui: &egui::Ui, rect: Rect) { /// strip itself is one flat wash, and the ramp happens in the empty picture /// above it, where there is nothing to obscure. fn scrim_bottom(ui: &egui::Ui, strip: Rect) { - const BANDS: usize = 12; let painter = ui.painter(); painter.rect_filled( strip, CornerRadius::ZERO, Color32::from_black_alpha(SCRIM_ALPHA as u8), ); + let above = Rect::from_min_max( + egui::pos2(strip.left(), strip.top() - SCRIM_FADE), + egui::pos2(strip.right(), strip.top()), + ); + // Squared again: the wash should meet the strip at full strength and be + // gone within a finger's width of picture above it. + fade(painter, above, |t| t * t * SCRIM_ALPHA); +} - let band = SCRIM_FADE / BANDS as f32; - for index in 0..BANDS { - let fraction = (index + 1) as f32 / BANDS as f32; - let rect = Rect::from_min_size( - egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32), - Vec2::new(strip.width(), band + 1.0), - ); - painter.rect_filled( - rect, - CornerRadius::ZERO, - // Squared again: the wash should meet the strip at full strength - // and be gone within a finger's width of picture above it. - Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8), - ); +/// Black over `rect`, at the alpha `curve` gives for each fraction of the way +/// down it. +/// +/// One mesh with a colour per row of vertices, which the GPU interpolates +/// between. It replaced a stack of flat bands, and the bands were what showed: +/// each overlapped the next by a pixel so no gap could open between them, and +/// every overlap was a line of double darkness across the picture. +fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) { + /// Enough rows that the curve between them reads as a curve. + const ROWS: u32 = 16; + let mut mesh = egui::Mesh::default(); + for row in 0..=ROWS { + let t = row as f32 / ROWS as f32; + let y = egui::lerp(rect.top()..=rect.bottom(), t); + let colour = Color32::from_black_alpha(curve(t).clamp(0.0, 255.0) as u8); + mesh.colored_vertex(egui::pos2(rect.left(), y), colour); + mesh.colored_vertex(egui::pos2(rect.right(), y), colour); + if row > 0 { + let base = (row - 1) * 2; + mesh.add_triangle(base, base + 1, base + 2); + mesh.add_triangle(base + 1, base + 3, base + 2); + } } + painter.add(egui::Shape::mesh(mesh)); +} + +/// What a key just did, in a pill near the top of the picture, for a moment. +/// +/// Returns whether it is still showing. +pub fn osd(ctx: &egui::Context, text: &str, at: f64) -> bool { + /// How long it stays, and how much of that is fading out. + const SECONDS: f64 = 0.9; + const FADE: f64 = 0.25; + + let age = ctx.input(|input| input.time) - at; + if age >= SECONDS { + return false; + } + let opacity = ((SECONDS - age) / FADE).clamp(0.0, 1.0) as f32; + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("osd"), + )); + let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Title.font(), Color32::WHITE); + let size = galley.size() + Vec2::new(36.0, 18.0); + let screen = ctx.content_rect(); + let frame = Rect::from_center_size( + egui::pos2(screen.center().x, screen.top() + TITLE_HEIGHT + 40.0), + size, + ); + painter.rect_filled( + frame, + CornerRadius::same((size.y / 2.0) as u8), + Color32::from_black_alpha((190.0 * opacity) as u8), + ); + painter.galley_with_override_text_color( + frame.min + Vec2::new(18.0, 9.0), + galley, + Color32::WHITE.gamma_multiply(opacity), + ); + ctx.request_repaint(); + true } diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index 479e0ec..81e68f2 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -20,9 +20,14 @@ use crate::ui; #[derive(Debug, Clone, Copy)] pub struct Prefs { pub autoplay: bool, + pub auto_skip: bool, pub appearance: Appearance, } +/// The widest the settings column gets. Rows that run the width of a 2K window +/// put a switch a whole screen away from what it switches. +const COLUMN: f32 = 760.0; + pub fn show( ui: &mut egui::Ui, settings: &mut MetadataConfig, @@ -33,13 +38,21 @@ pub fn show( egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - appearance_form(ui, prefs.appearance, actions); - - ui.add_space(28.0); - playback_form(ui, prefs.autoplay, actions); - - ui.add_space(28.0); - metadata_form(ui, settings, api_key, actions); + // Centred, so a wide window frames the column rather than leaving it + // against one edge with the rest of the page empty. + let width = ui.available_width().min(COLUMN); + let margin = ((ui.available_width() - width) / 2.0).max(0.0); + ui.horizontal(|ui| { + ui.add_space(margin); + ui.vertical(|ui| { + ui.set_width(width); + ui.add_space(8.0); + appearance_form(ui, prefs.appearance, actions); + playback_form(ui, prefs, actions); + metadata_form(ui, settings, api_key, actions); + about(ui); + }); + }); }); } @@ -49,19 +62,14 @@ pub fn show( /// there is no "apply" — because the preview *is* the app. A swatch row that /// only changed a small square would be asking someone to imagine the result. fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec) { - ui::section(ui, "Appearance"); - - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - ui.label(ui::muted("Palette")); + ui::widgets::settings_group(ui, "Appearance", None, |ui| { + ui.add_space(12.0); + ui.label(theme::Role::Body.rich("Palette").color(theme::text())); + ui.add_space(8.0); + ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0); + ui.horizontal_wrapped(|ui| { for flavor in Flavor::ALL { - if ui - .radio(appearance.flavor == flavor, flavor.label()) + if palette_card(ui, appearance, flavor) .on_hover_text(flavor.description()) .clicked() && appearance.flavor != flavor @@ -72,35 +80,114 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec< })); } } + }); + ui.add_space(14.0); + ui.spacing_mut().item_spacing.y = 0.0; - ui.add_space(12.0); - ui.label(ui::muted("Accent")); - ui.horizontal_wrapped(|ui| { - for accent in Accent::ALL { - if swatch(ui, appearance, accent).clicked() && appearance.accent != accent { - actions.push(Action::SetAppearance(Appearance { - accent, - ..appearance - })); - } + ui::widgets::settings_row(ui, "Accent", None, |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + // Right to left, so reversed to read in the order they are listed. + for accent in Accent::ALL.into_iter().rev() { + if swatch(ui, appearance, accent).clicked() && appearance.accent != accent { + actions.push(Action::SetAppearance(Appearance { + accent, + ..appearance + })); } - }); - - ui.add_space(12.0); - let mut gradients = appearance.gradients; - if ui - .checkbox(&mut gradients, "Fade the accent into a second colour") - .on_hover_text( - "Off draws it flat — better on a panel that bands a gradient into stripes", - ) - .changed() - { - actions.push(Action::SetAppearance(Appearance { - gradients, - ..appearance - })); } }); + + let mut gradients = appearance.gradients; + ui::widgets::settings_row( + ui, + "Gradient accent", + Some( + "Fade the accent into a second colour. Off draws it flat, which suits a panel that bands gradients into stripes.", + ), + |ui| { + if ui::widgets::toggle(ui, &mut gradients, "").changed() { + actions.push(Action::SetAppearance(Appearance { + gradients, + ..appearance + })); + } + }, + ); + }); +} + +/// One palette, as a miniature of the app wearing it: page, a card on it, a +/// line of text and the accent. +fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> egui::Response { + let size = egui::vec2(132.0, 92.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if ui.is_rect_visible(rect) { + let palette = theme::Palette::resolve(Appearance { + flavor, + ..appearance + }); + let selected = appearance.flavor == flavor; + let hover = ui + .ctx() + .animate_bool_with_time(response.id, response.hovered(), 0.10); + let painter = ui.painter(); + let radius = egui::CornerRadius::same(8); + let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0)); + + painter.rect_filled(preview, radius, palette.background); + let bar = egui::Rect::from_min_size(preview.min, egui::vec2(size.x, 12.0)); + painter.rect_filled( + bar, + egui::CornerRadius { + nw: 8, + ne: 8, + sw: 0, + se: 0, + }, + palette.surface, + ); + let tile = |x: f32| { + egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0)) + }; + for x in [8.0, 49.0, 90.0] { + painter.rect_filled(tile(x), egui::CornerRadius::same(3), palette.card); + } + let pill = + egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0)); + theme::swatch_fill(painter, pill, egui::CornerRadius::same(3), &palette); + painter.hline( + (preview.left() + 8.0)..=(preview.left() + 60.0), + preview.top() + 50.0, + egui::Stroke::new(2.0, palette.text), + ); + painter.hline( + (preview.left() + 8.0)..=(preview.left() + 40.0), + preview.top() + 56.0, + egui::Stroke::new(2.0, palette.muted), + ); + + let ring = if selected { + egui::Stroke::new(2.0, theme::accent()) + } else { + egui::Stroke::new( + 1.0, + theme::card_hover().lerp_to_gamma(theme::muted(), hover), + ) + }; + painter.rect_stroke(preview, radius, ring, egui::StrokeKind::Outside); + painter.text( + egui::pos2(rect.left() + 2.0, preview.bottom() + 8.0), + egui::Align2::LEFT_TOP, + flavor.label(), + theme::Role::Label.font(), + if selected { + theme::text() + } else { + theme::muted() + }, + ); + } + response.on_hover_cursor(egui::CursorIcon::PointingHand) } /// One accent, drawn in the colours it would actually produce. @@ -109,23 +196,22 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec< /// the row previews what clicking it would give rather than what is on screen. fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response { let selected = appearance.accent == accent; - let size = egui::Vec2::new(52.0, 26.0); + let size = egui::Vec2::splat(26.0); let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); if ui.is_rect_visible(rect) { let palette = theme::Palette::resolve(Appearance { accent, ..appearance }); - let radius = egui::CornerRadius::same(6); + let radius = egui::CornerRadius::same(13); theme::swatch_fill(ui.painter(), rect, radius, &palette); if selected { // A ring rather than a tick: the tick would have to be drawn in an // ink that reads on eight different fills. - ui.painter().rect_stroke( - rect.expand(2.0), - egui::CornerRadius::same(8), + ui.painter().circle_stroke( + rect.center(), + rect.width() / 2.0 + 3.0, egui::Stroke::new(2.0, theme::text()), - egui::StrokeKind::Outside, ); } } @@ -134,34 +220,61 @@ fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Re .on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The handful of playback settings that are not per-file. +/// The playback settings that are not per-file. /// -/// Volume, audio language and subtitle language are all set from the player -/// itself, where a viewer can hear the result — only autoplay has no natural -/// home there, because by the time it matters the episode is over. -fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec) { - ui::section(ui, "Playback"); +/// Volume, audio language, subtitle language and speed are all set from the +/// player itself, where a viewer can hear the result. +fn playback_form(ui: &mut egui::Ui, prefs: Prefs, actions: &mut Vec) { + ui::widgets::settings_group( + ui, + "Playback", + Some( + "Volume, audio, subtitles and speed are set from the player, and remembered from there.", + ), + |ui| { + let mut autoplay = prefs.autoplay; + ui::widgets::settings_row( + ui, + "Play the next episode", + Some("When an episode ends, start the next one. Never after a file that failed."), + |ui| { + if ui::widgets::toggle(ui, &mut autoplay, "").changed() { + actions.push(Action::SetAutoplay(autoplay)); + } + }, + ); + let mut auto_skip = prefs.auto_skip; + ui::widgets::settings_row( + ui, + "Skip openings and credits", + Some( + "Jump past chapters named as an opening, ending or preview, rather than offering a button. Chapter names are the release's guess, so this can skip a scene.", + ), + |ui| { + if ui::widgets::toggle(ui, &mut auto_skip, "").changed() { + actions.push(Action::SetAutoSkip(auto_skip)); + } + }, + ); + }, + ); +} - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - let mut enabled = autoplay; - if ui - .checkbox(&mut enabled, "Play the next episode automatically") - .on_hover_text("Only when an episode reaches its end, never after a failure") - .changed() - { - actions.push(Action::SetAutoplay(enabled)); - } - ui.label(ui::muted( - "Volume, audio language and subtitles are set from the player, and remembered \ - from there.", - )); +/// What this is, and which version. +fn about(ui: &mut egui::Ui) { + ui::widgets::settings_group(ui, "About", None, |ui| { + ui::widgets::settings_row( + ui, + "proton-stream", + Some("Streams Proton Drive share links without an account or a download step."), + |ui| { + ui.label(ui::muted(concat!("version ", env!("CARGO_PKG_VERSION")))); + }, + ); + ui::widgets::settings_row(ui, "Keyboard shortcuts", None, |ui| { + ui.label(ui::muted("press ?")); }); + }); } /// Enrichment: the switch, the provider, and the cost of turning it on. @@ -176,94 +289,86 @@ fn metadata_form( api_key: &mut String, actions: &mut Vec, ) { - ui::section(ui, "Posters and descriptions"); - - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - ui.label(ui::muted( - "A share filled by the Proton Drive desktop client carries no thumbnails, so \ + ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| { + ui.add_space(12.0); + ui.spacing_mut().item_spacing.y = 6.0; + ui.label(ui::muted( + "A share filled by the Proton Drive desktop client carries no thumbnails, so \ without this every tile is a pair of initials. Turning it on sends the titles \ in your library — not your files, and nothing about what you have watched — to \ the provider you choose, over HTTPS, each time a new one appears.", - )); - ui.add_space(10.0); + )); + ui.add_space(10.0); - let mut changed = false; - let mut enabled = settings.enabled; - if ui - .checkbox(&mut enabled, "Look up posters and descriptions") - .changed() - { - settings.enabled = enabled; - changed = true; + let mut changed = false; + let mut enabled = settings.enabled; + if ui::widgets::toggle(ui, &mut enabled, "Look up posters and descriptions").changed() { + settings.enabled = enabled; + changed = true; + } + + if settings.enabled { + ui.add_space(10.0); + ui.label(ui::muted("Provider")); + for provider in ProviderId::ALL { + if ui + .radio(settings.provider == provider, provider.label()) + .on_hover_text(provider.description()) + .clicked() + && settings.provider != provider + { + settings.provider = provider; + changed = true; + } } - if settings.enabled { + if settings.provider.needs_api_key() { ui.add_space(10.0); - ui.label(ui::muted("Provider")); - for provider in ProviderId::ALL { - if ui - .radio(settings.provider == provider, provider.label()) - .on_hover_text(provider.description()) - .clicked() - && settings.provider != provider - { - settings.provider = provider; - changed = true; - } - } - - if settings.provider.needs_api_key() { - ui.add_space(10.0); - ui.label(ui::muted(format!("{} API key", settings.provider.label()))); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(api_key) - .password(true) - .hint_text("v3 API key") - .desired_width(300.0), - ); - if ui.button("Save key").clicked() { - actions.push(Action::SetApiKey { - provider: settings.provider, - key: std::mem::take(api_key), - }); - } - }); - ui.label(ui::muted( - "Stored in your system keyring, never in a config file. Leave the box \ - empty and save to forget it.", - )); - } - - ui.add_space(12.0); + ui.label(ui::muted(format!("{} API key", settings.provider.label()))); ui.horizontal(|ui| { - if ui::accent_button(ui, "Match the library").clicked() { - actions.push(Action::MatchTitles { force: false }); - } - if ui - .button("Match everything again") - .on_hover_text("Ask about every title, including ones already matched") - .clicked() - { - actions.push(Action::MatchTitles { force: true }); + ui.add( + egui::TextEdit::singleline(api_key) + .password(true) + .hint_text("v3 API key") + .desired_width(300.0), + ); + if ui.button("Save key").clicked() { + actions.push(Action::SetApiKey { + provider: settings.provider, + key: std::mem::take(api_key), + }); } }); - } else { - ui.add_space(6.0); ui.label(ui::muted( - "Off. Nothing is sent anywhere, and turning it off also deletes the answers \ - already stored.", + "Stored in your system keyring, never in a config file. Leave the box \ + empty and save to forget it.", )); } - if changed { - actions.push(Action::SetMetadataConfig(settings.clone())); - } - }); + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui::accent_button(ui, "Match the library").clicked() { + actions.push(Action::MatchTitles { force: false }); + } + if ui + .button("Match everything again") + .on_hover_text("Ask about every title, including ones already matched") + .clicked() + { + actions.push(Action::MatchTitles { force: true }); + } + }); + } else { + ui.add_space(6.0); + ui.label(ui::muted( + "Off. Nothing is sent anywhere, and turning it off also deletes the answers \ + already stored.", + )); + } + + if changed { + actions.push(Action::SetMetadataConfig(settings.clone())); + } + ui.add_space(12.0); + }); } diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index b1610de..6891758 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -7,29 +7,43 @@ //! typed. use pstr_core::Share; +use pstr_core::library::Library; use crate::app::{Action, ShareForm}; use crate::theme; use crate::ui; -pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec) { +pub fn show( + ui: &mut egui::Ui, + shares: &[Share], + library: &Library, + form: &mut ShareForm, + actions: &mut Vec, +) { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { ui::section(ui, "Shares"); if shares.is_empty() { - ui.label(ui::muted("No shares yet.")); + ui.label(ui::muted( + "No shares yet. Add one below and its contents become your library.", + )); } for share in shares { - share_row(ui, share, actions); + let titles = library + .titles + .iter() + .filter(|title| title.share_ids.contains(&share.id)) + .count(); + share_row(ui, share, titles, actions); } ui.add_space(22.0); - add_form(ui, form, actions); + add_form(ui, shares, form, actions); }); } -fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec) { +fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec) { egui::Frame::new() .fill(theme::card()) .corner_radius(egui::CornerRadius::same(8)) @@ -39,12 +53,13 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec) { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(egui::RichText::new(&share.name).strong()); - let detail = if share.has_custom_password { - format!("{} · custom password", share.id) - } else { - share.id.clone() - }; - ui.label(ui::muted(detail)); + // What is in it, rather than what it is called inside the + // app. The id is still a hover away for a bug report. + let mut detail = ui::library::plural(titles, "title"); + if share.has_custom_password { + detail.push_str(" · password protected"); + } + ui.label(ui::muted(detail)).on_hover_text(&share.id); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -64,7 +79,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec) { ui.add_space(6.0); } -fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec) { +fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec) { ui::section(ui, "Add a share"); ui.label(ui::muted( "Paste the whole link, including everything after the # — that part is the key that \ @@ -88,15 +103,40 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec) ui.add_space(8.0); ui.label(ui::muted("Link")); - ui.add( - egui::TextEdit::singleline(&mut form.url) - .password(true) - .hint_text("https://drive.proton.me/urls/…#…") - .desired_width(f32::INFINITY), - ); + ui.horizontal(|ui| { + let paste = 60.0; + let field = egui::Id::new("share-link"); + ui.add( + egui::TextEdit::singleline(&mut form.url) + .id(field) + .password(true) + .hint_text("https://drive.proton.me/urls/…#…") + .desired_width(ui.available_width() - paste), + ); + // The link is masked, so pasting is the only sensible way in — + // and a button says that more plainly than the field does. + if ui + .add_sized( + [paste - 8.0, ui.spacing().interact_size.y], + egui::Button::new("Paste"), + ) + .clicked() + { + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::RequestPaste); + // The paste lands in whatever has focus, so give it some. + ui.memory_mut(|memory| memory.request_focus(field)); + } + }); + // Said while typing, not after a refused submit: everything this + // checks is knowable from the text alone. + let problem = link_problem(&form.url, shares); + if let Some(problem) = &problem { + ui.label(theme::Role::Caption.rich(problem).color(theme::danger())); + } ui.add_space(8.0); - ui.checkbox(&mut form.has_password, "The link asks for a password"); + ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password"); if form.has_password { ui.add_space(6.0); ui.add( @@ -110,8 +150,14 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec) ui.add_space(12.0); let ready = !form.name.trim().is_empty() && !form.url.trim().is_empty() - && (!form.has_password || !form.password.is_empty()); + && problem.is_none() + && (!form.has_password || !form.password.is_empty()) + && !form.sending; + if let Some(error) = &form.error { + ui.label(theme::Role::Caption.rich(error).color(theme::danger())); + ui.add_space(6.0); + } ui.horizontal(|ui| { if ui .add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl")) @@ -127,9 +173,70 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec) .filter(|password| !password.is_empty()), }); } - if !ready { + if form.sending { + ui.add(egui::Spinner::new().size(14.0)); + ui.label(ui::muted("adding…")); + } else if form.name.trim().is_empty() || form.url.trim().is_empty() { ui.label(ui::muted("A name and a link are needed.")); } }); }); } + +/// What is wrong with a link as typed, if anything. +/// +/// Nothing for an empty box — that is "not done yet", which the button already +/// says. +fn link_problem(url: &str, shares: &[Share]) -> Option { + let url = url.trim(); + if url.is_empty() { + return None; + } + match pstr_core::shares::share_token(url) { + Ok(token) if shares.iter().any(|share| share.token == token) => { + Some("That share is already added.".into()) + } + Ok(_) => None, + Err(error) => Some(error.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn share(token: &str) -> Share { + Share { + id: format!("share-{token}"), + name: "anime".into(), + token: token.into(), + has_custom_password: false, + } + } + + #[test] + fn an_empty_link_is_not_yet_a_problem() { + assert_eq!(link_problem(" ", &[]), None); + } + + #[test] + fn a_link_without_its_key_is_refused_while_typing() { + assert!(link_problem("https://drive.proton.me/urls/ABC123", &[]).is_some()); + assert!(link_problem("https://example.com/", &[]).is_some()); + assert_eq!( + link_problem("https://drive.proton.me/urls/ABC123#key", &[]), + None + ); + } + + #[test] + fn a_share_already_added_is_refused_while_typing() { + assert!( + link_problem( + "https://drive.proton.me/urls/ABC123#key", + &[share("ABC123")] + ) + .is_some() + ); + } +} diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs new file mode 100644 index 0000000..9511e81 --- /dev/null +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -0,0 +1,80 @@ +//! The list of keyboard shortcuts, on `?`. +//! +//! Written out here rather than generated from the handlers, because the +//! handlers are split between the player page and every other page, and what a +//! viewer needs is one list grouped by where they are. + +use crate::theme; +use crate::ui; + +const GROUPS: &[(&str, &[(&str, &str)])] = &[ + ( + "Anywhere", + &[ + ("Ctrl F or /", "Search the library"), + ("F5 or Ctrl R", "Crawl every share again"), + ("Ctrl 1 – 4", "Library, Shares, Downloads, Settings"), + ("Ctrl ,", "Settings"), + ("Alt ← or mouse back", "Back"), + ("?", "This list"), + ], + ), + ( + "Playing", + &[ + ("Space or K", "Pause and play"), + ("← / →", "Back 10 s, forward 30 s"), + ("↑ / ↓", "Volume"), + ("M", "Mute"), + ("N / P", "Next and previous episode"), + ("[ / ]", "Slower and faster"), + ("S", "Skip the opening or the credits"), + ("F or double-click", "Fullscreen"), + ("Esc", "Leave fullscreen, then the player"), + ], + ), +]; + +/// Draw the list. Returns whether it asked to be closed. +pub fn show(ctx: &egui::Context) -> bool { + let mut close = false; + let modal = egui::Modal::new(egui::Id::new("shortcuts")) + .frame( + egui::Frame::new() + .fill(theme::surface()) + .inner_margin(egui::Margin::same(20)) + .corner_radius(egui::CornerRadius::same(12)), + ) + .show(ctx, |ui| { + ui.set_width(460.0); + ui.horizontal(|ui| { + ui.label(theme::Role::Heading.rich("Keyboard shortcuts").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Close").clicked() { + close = true; + } + }); + }); + for (heading, keys) in GROUPS { + ui.add_space(12.0); + ui.label(ui::muted(heading.to_uppercase())); + ui.add_space(4.0); + egui::Grid::new(*heading) + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + for (key, what) in *keys { + ui.label( + theme::Role::Label + .rich(*key) + .monospace() + .color(theme::accent()), + ); + ui.label(theme::Role::Body.rich(*what)); + ui.end_row(); + } + }); + } + }); + close || modal.should_close() +} diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 4580aba..0e70716 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -30,10 +30,10 @@ pub fn show( return; }; - if ui.button("Library").clicked() { + if back_link(ui).clicked() { actions.push(Action::Goto(Page::Library)); } - ui.add_space(10.0); + ui.add_space(6.0); egui::ScrollArea::vertical() .auto_shrink([false, false]) @@ -41,54 +41,142 @@ pub fn show( header(ui, art, title, &offline, actions); ui.add_space(18.0); - for (index, season) in title.seasons.iter().enumerate() { - let open = index == 0 || title.seasons.len() == 1; - egui::CollapsingHeader::new( - theme::Role::Subhead - .rich(format!( - "{} · {}", - season.label(), - ui::library::plural(season.episodes.len(), "episode") - )) - .strong(), - ) - .id_salt(("season", index)) - .default_open(open) - .show(ui, |ui| { - let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect(); - let all_offline = season_keys.iter().all(|key| offline.files.contains(key)); - if all_offline { - if ui - .button("Delete season") - .on_hover_text("Delete the offline copies; keep the online source") - .clicked() - { - for key in season_keys { - actions.push(Action::RemoveDownload(key, false)); - } - } - } else if ui - .button("Download season") - .on_hover_text("Download every episode in this season") - .clicked() - { - actions.push(Action::MakeOffline( - season - .episodes - .iter() - .map(|episode| PlaybackTarget::new(title, episode)) - .collect(), - )); - } - for episode in &season.episodes { - episode_row(ui, art, title, season, episode, &offline, actions); + // One season at a time, picked from a row of pills, rather than + // every season stacked as a collapsing header: a four-season show + // was a page of headers to scroll past to reach the one being + // watched. The one opened first is the one with the next episode. + let picked_id = ui.id().with(("season", &title.key)); + let default = title + .next_up() + .and_then(|next| { + title.seasons.iter().position(|season| { + season + .episodes + .iter() + .any(|episode| episode.node.link_id == next.node.link_id) + }) + }) + .unwrap_or(0); + let mut picked = ui + .data(|data| data.get_temp::(picked_id)) + .unwrap_or(default) + .min(title.seasons.len().saturating_sub(1)); + + let Some(season) = title.seasons.get(picked) else { + return; + }; + ui.horizontal(|ui| { + if title.seasons.len() > 1 { + let labels: Vec = + title.seasons.iter().map(|season| season.label()).collect(); + let choices: Vec<(usize, &str)> = labels + .iter() + .enumerate() + .map(|(index, label)| (index, label.as_str())) + .collect(); + if let Some(index) = ui::widgets::segmented(ui, picked, &choices) { + picked = index; + ui.data_mut(|data| data.insert_temp(picked_id, index)); } + ui.add_space(10.0); + } + ui.label(ui::muted(ui::library::plural( + season.episodes.len(), + "episode", + ))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + season_download(ui, title, season, &offline, actions); }); - ui.add_space(6.0); + }); + ui.add_space(10.0); + + let Some(season) = title.seasons.get(picked) else { + return; + }; + for episode in &season.episodes { + episode_row(ui, art, title, season, episode, &offline, actions); } + ui.add_space(12.0); }); } +/// A title's poster, at the poster's own shape, with how far into it the +/// viewer is along its foot. +fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option) { + let size = egui::vec2(180.0, 270.0); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + let radius = egui::CornerRadius::same(10); + ui.painter() + .add(theme::tile_shadow(1.0).as_shape(rect, radius)); + ui.painter().add( + egui::epaint::RectShape::filled(rect, radius, egui::Color32::WHITE) + .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), size)), + ); + if let Some(progress) = progress { + let track = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.bottom() - 4.0), + rect.right_bottom(), + ); + let painter = ui.painter().with_clip_rect(track); + painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150)); + let mut played = rect; + played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32); + theme::accent_fill(&painter, played, radius, 0.0); + } +} + +/// "← Library", as a link rather than a button: it is navigation, and a +/// filled button beside the page's real actions competed with them. +fn back_link(ui: &mut egui::Ui) -> egui::Response { + let text = theme::Role::Label.rich("← Library"); + let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click())); + if response.hovered() { + ui.painter().hline( + response.rect.x_range(), + response.rect.bottom(), + egui::Stroke::new(1.0, theme::muted()), + ); + } + response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("Back (Esc, Alt ←)") +} + +/// Download, or delete, a whole season. +fn season_download( + ui: &mut egui::Ui, + title: &Title, + season: &Season, + offline: &OfflineView<'_>, + actions: &mut Vec, +) { + let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect(); + let all_offline = season_keys.iter().all(|key| offline.files.contains(key)); + if all_offline { + if ui + .button("Delete season") + .on_hover_text("Delete the offline copies; keep the online source") + .clicked() + { + for key in season_keys { + actions.push(Action::RemoveDownload(key, false)); + } + } + } else if ui + .button("Download season") + .on_hover_text("Download every episode in this season") + .clicked() + { + actions.push(Action::MakeOffline( + season + .episodes + .iter() + .map(|episode| PlaybackTarget::new(title, episode)) + .collect(), + )); + } +} + /// The still, the name and the one button that matters. fn header( ui: &mut egui::Ui, @@ -115,20 +203,32 @@ fn header( ui.add_space(6.0); ui.horizontal_top(|ui| { - ui::card( - ui, - Card { - art: picture, - name: &title.name, - subtitle: String::new(), - progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), - badge: None, - width: theme::CARD_WIDTH, - }, - ); + match &picture { + // A poster is drawn as a poster. Fitted into the 16:9 tile the grid + // uses, it was a narrow strip of picture between two bars. + Some((texture, pstr_core::metadata::ArtShape::Portrait)) => { + poster(ui, texture, title.resume().and_then(|e| e.progress())); + } + _ => { + ui::card( + ui, + Card { + art: picture, + name: &title.name, + subtitle: String::new(), + progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), + badge: None, + width: theme::CARD_WIDTH, + }, + ); + } + } - ui.add_space(18.0); + ui.add_space(22.0); ui.vertical(|ui| { + // Text in a column a person can read, with the picture to the + // right of it rather than under it. + ui.set_max_width(ui.available_width().min(820.0)); ui.label(theme::Role::Display.rich(&title.name).strong()); // The provider's name for it, when it is not the one the files use. // Worth showing rather than replacing the filename's: a viewer @@ -149,9 +249,17 @@ fn header( ui.horizontal(|ui| { if let Some(episode) = title.next_up() { let resuming = episode.resume_at().is_some(); + // Where it resumes, in the button that resumes it. As a + // label of its own it ended up three controls further + // along, after the download buttons, reading as a fact + // about those. + let at = episode + .resume_at() + .map(|at| format!(" · {}", ui::format_time(at))) + .unwrap_or_default(); let label = match (resuming, episode.numbering()) { - (true, Some(numbering)) => format!("Resume {numbering}"), - (true, None) => "Resume".to_string(), + (true, Some(numbering)) => format!("Resume {numbering}{at}"), + (true, None) => format!("Resume{at}"), (false, Some(numbering)) if title.kind == TitleKind::Series => { format!("Play {numbering}") } @@ -160,6 +268,19 @@ fn header( if ui::accent_button(ui, &label).clicked() { actions.push(Action::Play(PlaybackTarget::new(title, episode))); } + // Beside the button it is the alternative to. + if resuming + && ui + .button("Start over") + .on_hover_text("Play from the beginning") + .clicked() + { + actions.push(Action::Play(PlaybackTarget::from_node( + title, + &episode.node, + None, + ))); + } let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); if all_offline { @@ -202,20 +323,6 @@ fn header( } else if all_offline { ui.label(ui::muted("Available offline")); } - if resuming && let Some(at) = episode.resume_at() { - ui.label(ui::muted(format!("at {}", ui::format_time(at)))); - if ui - .button("Start over") - .on_hover_text("Play from the beginning") - .clicked() - { - actions.push(Action::Play(PlaybackTarget::from_node( - title, - &episode.node, - None, - ))); - } - } } // The way out of a match the scorer would not make, or made @@ -299,7 +406,10 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S egui::epaint::RectShape::filled( rect, egui::CornerRadius::ZERO, - theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }), + // Lighter with the ramps on: the horizontal one below is solid + // where the text is, so this coat only has to calm the picture's + // right side, not make it disappear. + theme::background().gamma_multiply(if ramps { 0.72 } else { 0.94 }), ) .into(), ); @@ -340,11 +450,50 @@ fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::S /// What the provider had to say. Only ever drawn under a match. fn description(ui: &mut egui::Ui, found: &TitleMetadata) { if !found.genres.is_empty() { - ui.label(ui::muted(found.genres.join(" · "))); - ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for genre in &found.genres { + ui::widgets::chip(ui, genre); + } + }); + ui.add_space(8.0); } if let Some(overview) = &found.overview { - ui.add(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap()); + // Four lines, then "More". A provider's synopsis runs to three + // paragraphs on a long show, and all of them pushed the episodes — + // what the page is for — below the fold. + const LINES: usize = 4; + let id = ui.id().with(("overview", &found.remote_id)); + let open = ui.data(|data| data.get_temp::(id)).unwrap_or(false); + let mut job = egui::text::LayoutJob::simple( + overview.clone(), + theme::Role::Label.font(), + theme::text(), + ui.available_width(), + ); + if !open { + job.wrap.max_rows = LINES; + job.wrap.overflow_character = Some('…'); + } + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let clipped = galley.elided; + ui.label(galley); + if clipped || open { + let toggle = ui.add( + egui::Label::new( + theme::Role::Caption + .rich(if open { "Less" } else { "More" }) + .color(theme::accent()), + ) + .sense(egui::Sense::click()), + ); + if toggle + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + ui.data_mut(|data| data.insert_temp(id, !open)); + } + } } if let Some(url) = &found.url { ui.add_space(8.0); @@ -398,7 +547,11 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { parts.join(" · ") } -/// One line per file: click anywhere on it to play. +/// How big an episode's still is drawn. +const STILL: egui::Vec2 = egui::vec2(160.0, 90.0); + +/// One row per file: its still, its name and what it is about, and the two +/// things that can be done to it besides playing it. fn episode_row( ui: &mut egui::Ui, art: &mut Art<'_>, @@ -417,158 +570,292 @@ fn episode_row( ) }); let watched = episode.is_watched(); - egui::Frame::new() - .fill(if watched { - theme::background() - } else { - theme::card() - }) - .corner_radius(egui::CornerRadius::same(6)) - .inner_margin(egui::Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - // An explicit target rather than a clickable row: the row also - // carries a checkbox, and a click that means "seen" must never - // be one that starts a 1.4 GiB stream instead. - if ui - .add(egui::Button::new(theme::Role::Label.rich("▶")).fill(theme::card_hover())) - .on_hover_text("Play") - .clicked() - { - actions.push(Action::Play(PlaybackTarget::new(title, episode))); - } - let key = key_of(episode); - let current = offline.downloads.iter().find(|item| item.key == key); - if offline.files.contains(&key) { - if ui - .small_button("Delete") - .on_hover_text("Delete the offline copy; keep the online source") - .clicked() - { - actions.push(Action::RemoveDownload(key.clone(), false)); - } - ui.label(ui::muted("✓ Offline")); - } else if let Some(item) = current { - match item.state { - DownloadState::Running | DownloadState::Queued => { - if ui.small_button("Pause").clicked() { - actions.push(Action::PauseDownload(key.clone())); - } - ui.label(ui::muted(format!("↓ {:.0}%", item.percent() * 100.0))); - } - DownloadState::Paused => { - if ui.small_button("Resume").clicked() { - actions.push(Action::ResumeDownload(key.clone())); - } - ui.label(ui::muted("Paused · partial kept")); - } - DownloadState::Cancelled | DownloadState::Failed(_) => { - if ui.small_button("Resume").clicked() { - actions.push(Action::ResumeDownload(key.clone())); - } - ui.label(ui::muted("Partial")); - } - DownloadState::Completed => { - ui.label(ui::muted("✓ Offline")); - } - } - } else if ui - .small_button("Download") - .on_hover_text("Make available offline") - .clicked() - { - actions.push(Action::MakeOffline(vec![PlaybackTarget::new( - title, episode, - )])); - } + let row_id = ui.id().with(("episode", &episode.node.link_id)); - let numbering = episode - .numbering() - .or_else(|| season.number.map(|number| format!("S{number:02}"))) - .unwrap_or_default(); - ui.add_sized( - [66.0, 18.0], - egui::Label::new( - egui::RichText::new(numbering) - .monospace() - .color(theme::muted()), - ), - ); + let frame = egui::Frame::new() + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(8)); + let mut prepared = frame.begin(ui); + { + let ui = &mut prepared.content_ui; + ui.set_width(ui.available_width()); + ui.horizontal_top(|ui| { + still(ui, art, title, episode, watched, actions); + ui.add_space(6.0); + + let controls = 96.0; + let text_width = (ui.available_width() - controls).max(120.0); + ui.vertical(|ui| { + ui.set_width(text_width); + ui.spacing_mut().item_spacing.y = 3.0; // The provider's name for the episode, when there is one: // "The Immortal Legion" reads as an episode, and // "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename. + let numbering = episode + .numbering() + .or_else(|| season.number.map(|number| format!("S{number:02}"))) + .unwrap_or_default(); let name = found .as_ref() .and_then(|(name, _, _)| name.clone()) .unwrap_or_else(|| episode.detail().to_string()); - let label = egui::RichText::new(name).color(if watched { - theme::muted() - } else { - theme::text() - }); - let hover = match &found { - // The synopsis, where the provider has one — AniList does - // not, and the filename is worth showing either way. - Some((_, Some(overview), _)) => { - format!("{}\n\n{overview}", episode.node.name) - } - _ => episode.node.name.clone(), - }; - ui.add(egui::Label::new(label).truncate()) - .on_hover_text(hover); - - if let Some((_, _, Some(air_date))) = &found { - ui.label(ui::muted(air_date.clone())); + let mut job = egui::text::LayoutJob::default(); + if !numbering.is_empty() { + job.append( + &format!("{numbering} "), + 0.0, + egui::TextFormat::simple(egui::FontId::monospace(13.0), theme::muted()), + ); } - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let mut checked = watched; - if ui - .checkbox(&mut checked, "") - .on_hover_text(if watched { - "Mark unwatched" + job.append( + &name, + 0.0, + egui::TextFormat::simple( + theme::Role::Body.font(), + if watched { + theme::muted() } else { - "Mark watched" - }) - .changed() - { - actions.push(Action::SetWatched { - share_id: episode.node.share_id.clone(), - link_id: episode.node.link_id.clone(), - watched: checked, - duration: episode.watch.and_then(|watch| watch.duration_secs), - }); - } + theme::text() + }, + ), + ); + job.wrap.max_rows = 1; + job.wrap.overflow_character = Some('…'); + ui.add(egui::Label::new(job).truncate()) + .on_hover_text(&episode.node.name); - match episode.node.size { - // A file the share says is empty is one that will not - // play — an upload that never finished, usually. Worth - // saying so on the row rather than only when a click on - // it comes back with "has no content". - Some(0) => { - ui.label(theme::Role::Caption.rich("empty").color(theme::danger())) - .on_hover_text( - "The share reports no content for this file; it cannot be played.", - ); - } - Some(size) => { - ui.label(ui::muted(ui::format_size(size))); - } - None => {} - } - if let Some(at) = episode.resume_at() { - ui.label( - theme::Role::Caption - .rich(format!("resume {}", ui::format_time(at))) - .color(theme::accent()), - ); - } - }); + ui.label(ui::muted(details( + episode, + found.as_ref().and_then(|f| f.2.as_deref()), + ))); + + if let Some((_, Some(overview), _)) = &found { + let mut job = egui::text::LayoutJob::simple( + overview.clone(), + theme::Role::Caption.font(), + theme::muted(), + text_width, + ); + job.wrap.max_rows = 2; + job.wrap.overflow_character = Some('…'); + ui.add(egui::Label::new(job)).on_hover_text(overview); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_height(STILL.y); + if ui::widgets::watched_mark(ui, watched) + .on_hover_text(if watched { + "Mark unwatched" + } else { + "Mark watched" + }) + .clicked() + { + actions.push(Action::SetWatched { + share_id: episode.node.share_id.clone(), + link_id: episode.node.link_id.clone(), + watched: !watched, + duration: episode.watch.and_then(|watch| watch.duration_secs), + }); + } + ui.add_space(6.0); + download(ui, title, episode, offline, actions); }); }); - ui.add_space(4.0); + } + // The row lights up under the pointer, so it is clear which file the + // buttons on the right belong to in a list of thirty. + let response = prepared.allocate_space(ui); + let lit = ui.ctx().animate_bool_with_time( + row_id, + response.hovered() || response.contains_pointer(), + 0.10, + ); + prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit); + prepared.paint(ui); + ui.add_space(2.0); +} + +/// The grey line under an episode's name: when it aired, how big it is, and +/// where it would resume. +fn details(episode: &Episode, air_date: Option<&str>) -> String { + let mut parts = Vec::new(); + if let Some(at) = episode.resume_at() { + let left = episode + .watch + .and_then(|watch| watch.duration_secs) + .map(|duration| format!(" · {} left", ui::format_time(duration - at))) + .unwrap_or_default(); + parts.push(format!("Resume at {}{left}", ui::format_time(at))); + } else if episode.is_watched() { + parts.push("Watched".into()); + } + if let Some(date) = air_date { + parts.push(date.to_owned()); + } + match episode.node.size { + // A file the share says is empty is one that will not play — an upload + // that never finished, usually. Worth saying on the row rather than + // only when a click on it comes back with "has no content". + Some(0) => parts.push("empty — this file cannot be played".into()), + Some(size) => parts.push(ui::format_size(size)), + None => {} + } + parts.join(" · ") +} + +/// The still, which is also the play button. +/// +/// An explicit target rather than a clickable row: the row also carries the +/// watched mark and the download button, and a click that meant either of +/// those must never be one that starts a 1.4 GiB stream instead. +fn still( + ui: &mut egui::Ui, + art: &mut Art<'_>, + title: &Title, + episode: &Episode, + watched: bool, + actions: &mut Vec, +) { + let (rect, response) = ui.allocate_exact_size(STILL, egui::Sense::click()); + if !ui.is_rect_visible(rect) { + return; + } + let radius = egui::CornerRadius::same(8); + let painter = ui.painter(); + painter.rect_filled(rect, radius, theme::card_hover()); + // Asked for only once the row is on screen: a season of twenty-five is + // twenty-five requests, and most of them are never scrolled to. + if let Some(texture) = art.still(&title.key, episode) { + let tint = if watched { + egui::Color32::from_gray(150) + } else { + egui::Color32::WHITE + }; + painter.add( + egui::epaint::RectShape::filled(rect, radius, tint) + .with_texture(texture.id(), ui::cover_uv(texture.size_vec2(), rect.size())), + ); + } + + let hover = ui + .ctx() + .animate_bool_with_time(response.id, response.hovered(), 0.12); + if hover > 0.0 { + painter.rect_filled( + rect, + radius, + egui::Color32::from_black_alpha((90.0 * hover) as u8), + ); + } + // A play mark, always faintly there and full under the pointer. + let centre = rect.center(); + painter.circle_filled( + centre, + 18.0, + egui::Color32::from_black_alpha((110.0 + 80.0 * hover) as u8), + ); + let ink = egui::Color32::WHITE.gamma_multiply(0.75 + 0.25 * hover); + let r = 7.0; + painter.add(egui::Shape::convex_polygon( + vec![ + centre + egui::vec2(-r * 0.6, -r), + centre + egui::vec2(r, 0.0), + centre + egui::vec2(-r * 0.6, r), + ], + ink, + egui::Stroke::NONE, + )); + + if let Some(progress) = episode.progress() { + let track = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.bottom() - 4.0), + rect.right_bottom(), + ); + let painter = painter.with_clip_rect(track); + painter.rect_filled(rect, radius, egui::Color32::from_black_alpha(150)); + let mut played = rect; + played.set_width(rect.width() * progress.clamp(0.0, 1.0) as f32); + theme::accent_fill(&painter, played, radius, 0.0); + } + + if response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(if episode.resume_at().is_some() { + "Resume" + } else { + "Play" + }) + .clicked() + { + actions.push(Action::Play(PlaybackTarget::new(title, episode))); + } +} + +/// One button that is the file's whole offline story: download it, see it +/// coming, pause it, or delete the copy. +fn download( + ui: &mut egui::Ui, + title: &Title, + episode: &Episode, + offline: &OfflineView<'_>, + actions: &mut Vec, +) { + use ui::widgets::{DownloadGlyph, download_button}; + + let key = key_of(episode); + let current = offline.downloads.iter().find(|item| item.key == key); + if offline.files.contains(&key) { + if download_button(ui, DownloadGlyph::Done) + .on_hover_text("Available offline. Click to delete the copy; the share keeps the file.") + .clicked() + { + actions.push(Action::RemoveDownload(key, false)); + } + return; + } + match current.map(|item| (&item.state, item.percent())) { + Some((DownloadState::Running | DownloadState::Queued, fraction)) => { + if download_button(ui, DownloadGlyph::Progress(fraction)) + .on_hover_text(format!( + "Downloading, {:.0}%. Click to pause.", + fraction * 100.0 + )) + .clicked() + { + actions.push(Action::PauseDownload(key)); + } + } + Some(( + DownloadState::Paused | DownloadState::Cancelled | DownloadState::Failed(_), + fraction, + )) => { + if download_button(ui, DownloadGlyph::Paused(fraction)) + .on_hover_text(format!( + "Stopped at {:.0}%. Click to carry on.", + fraction * 100.0 + )) + .clicked() + { + actions.push(Action::ResumeDownload(key)); + } + } + Some((DownloadState::Completed, _)) => { + download_button(ui, DownloadGlyph::Done).on_hover_text("Available offline"); + } + None => { + if download_button(ui, DownloadGlyph::Download) + .on_hover_text("Download, to play without a connection") + .clicked() + { + actions.push(Action::MakeOffline(vec![PlaybackTarget::new( + title, episode, + )])); + } + } + } } fn key_of(episode: &Episode) -> DownloadKey { diff --git a/crates/pstr-app/src/ui/toast.rs b/crates/pstr-app/src/ui/toast.rs new file mode 100644 index 0000000..583b900 --- /dev/null +++ b/crates/pstr-app/src/ui/toast.rs @@ -0,0 +1,207 @@ +//! Short messages in the corner, for a few seconds. +//! +//! They replaced a single status line along the bottom of the window, which had +//! three problems a stack does not: a second message wiped the first before it +//! could be read, an error looked like any other line in a slightly different +//! grey, and the line took a strip of the page with it every time it appeared. + +use egui::{Align2, Color32, CornerRadius, Sense, Stroke, Vec2}; + +use crate::theme; + +/// How long a message stays up. +const SECONDS: f64 = 5.0; +/// How long an error stays up. Longer: it is more likely to be read twice. +const ERROR_SECONDS: f64 = 9.0; +/// How many fit at once. A burst beyond that drops the oldest. +const MOST: usize = 4; +/// How long one takes to arrive and to leave. +const FADE: f64 = 0.18; +const WIDTH: f32 = 340.0; + +pub struct Toast { + id: u64, + text: String, + error: bool, + at: f64, +} + +/// The messages on screen. +#[derive(Default)] +pub struct Toasts { + items: Vec, + next: u64, +} + +impl Toasts { + pub fn push(&mut self, now: f64, text: impl Into, error: bool) { + let text = text.into(); + // The same message again — a retry that failed the same way — restarts + // the one already up rather than stacking a copy of it. + if let Some(existing) = self.items.iter_mut().find(|toast| toast.text == text) { + existing.at = now; + existing.error = error; + return; + } + self.next += 1; + self.items.push(Toast { + id: self.next, + text, + error, + at: now, + }); + if self.items.len() > MOST { + self.items.remove(0); + } + } + + /// Draw the stack above `bottom` points from the window's bottom edge, and + /// drop whatever has expired or been closed. + pub fn show(&mut self, ctx: &egui::Context, bottom: f32) { + let now = ctx.input(|input| input.time); + let pointer = ctx.input(|input| input.pointer.hover_pos()); + let mut closed = None; + let mut hovered = false; + let mut y = ctx.content_rect().bottom() - bottom - 16.0; + let right = ctx.content_rect().right() - 16.0; + + // Newest at the bottom, nearest where the eye already is. + for toast in self.items.iter().rev() { + let life = lifetime(toast.error); + let age = now - toast.at; + let arriving = (age / FADE).clamp(0.0, 1.0) as f32; + let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32; + let opacity = ease(arriving.min(leaving)); + + let area = egui::Area::new(egui::Id::new(("toast", toast.id))) + .order(egui::Order::Foreground) + .pivot(Align2::RIGHT_BOTTOM) + .fixed_pos(egui::pos2(right + (1.0 - opacity) * 24.0, y)) + .interactable(true) + .show(ctx, |ui| { + ui.set_opacity(opacity); + card(ui, toast) + }); + if area.inner { + closed = Some(toast.id); + } + if pointer.is_some_and(|at| area.response.rect.contains(at)) { + hovered = true; + } + y -= area.response.rect.height() + 8.0; + } + + if let Some(id) = closed { + self.items.retain(|toast| toast.id != id); + } + // Under the pointer, nothing leaves: someone is reading it. + if hovered { + for toast in &mut self.items { + let life = lifetime(toast.error); + toast.at = toast.at.max(now - life + 1.5); + } + } + self.items + .retain(|toast| now - toast.at < lifetime(toast.error)); + if !self.items.is_empty() { + // Enough frames to animate the edges and to notice an expiry. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } +} + +fn lifetime(error: bool) -> f64 { + if error { ERROR_SECONDS } else { SECONDS } +} + +fn ease(t: f32) -> f32 { + 1.0 - (1.0 - t).powi(3) +} + +/// One message. Returns whether its close button was pressed. +fn card(ui: &mut egui::Ui, toast: &Toast) -> bool { + let mut close = false; + let edge = if toast.error { + theme::danger() + } else { + theme::accent() + }; + egui::Frame::new() + .fill(theme::surface()) + .stroke(Stroke::new(1.0, theme::card_hover())) + .corner_radius(CornerRadius::same(10)) + .inner_margin(egui::Margin { + left: 16, + right: 10, + top: 10, + bottom: 10, + }) + .shadow(egui::Shadow { + offset: [0, 6], + blur: 18, + spread: 0, + color: Color32::from_black_alpha(90), + }) + .show(ui, |ui| { + ui.set_width(WIDTH); + let frame = ui.max_rect(); + ui.horizontal(|ui| { + ui.add( + egui::Label::new(theme::Role::Label.rich(&toast.text).color(theme::text())) + .wrap(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let (rect, response) = + ui.allocate_exact_size(Vec2::splat(18.0), Sense::click()); + let ink = if response.hovered() { + theme::text() + } else { + theme::muted() + }; + let d = 4.0; + let c = rect.center(); + ui.painter().line_segment( + [c + Vec2::new(-d, -d), c + Vec2::new(d, d)], + Stroke::new(1.5, ink), + ); + ui.painter().line_segment( + [c + Vec2::new(-d, d), c + Vec2::new(d, -d)], + Stroke::new(1.5, ink), + ); + close = response.on_hover_text("Dismiss").clicked(); + }); + }); + // A strip down the leading edge says which kind it is before a word + // of it is read. + let strip = egui::Rect::from_min_max( + egui::pos2(frame.left() - 10.0, frame.top() - 4.0), + egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0), + ); + ui.painter().rect_filled(strip, CornerRadius::same(2), edge); + }); + close +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_repeated_message_restarts_rather_than_stacks() { + let mut toasts = Toasts::default(); + toasts.push(0.0, "could not open", true); + toasts.push(3.0, "could not open", true); + assert_eq!(toasts.items.len(), 1); + assert_eq!(toasts.items[0].at, 3.0); + } + + #[test] + fn a_burst_keeps_only_the_newest() { + let mut toasts = Toasts::default(); + for index in 0..10 { + toasts.push(0.0, format!("message {index}"), false); + } + assert_eq!(toasts.items.len(), MOST); + assert_eq!(toasts.items.last().unwrap().text, "message 9"); + } +} diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index ba0e5f7..9ac69d6 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -94,6 +94,8 @@ pub fn full( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { track_menus(ui, playback, actions); ui.add_space(8.0); + speed_menu(ui, playback, actions); + ui.add_space(8.0); chapter_menu(ui, playback, actions); ui.add_space(8.0); volume(ui, playback, actions); @@ -429,6 +431,32 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: } } +/// How fast it plays. Says so on the button only when it is not normal speed, +/// because that is the one case worth noticing at a glance. +fn speed_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) { + let label = if (playback.speed - 1.0).abs() < 1e-6 { + "Speed".to_owned() + } else { + format!("Speed · {}", ui::format_speed(playback.speed)) + }; + ui.menu_button(label, |ui| { + for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] { + if ui + .selectable_label( + (playback.speed - speed).abs() < 1e-6, + ui::format_speed(speed), + ) + .clicked() + { + actions.push(Action::SetSpeed(speed)); + ui.close(); + } + } + }) + .response + .on_hover_text("Playback speed ([ and ])"); +} + /// Jump to a chapter. Only drawn for a file that has more than one, which for /// most releases means anime and not much else. fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) { @@ -470,66 +498,87 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec, t let (rect, response) = ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag()); - let hovered = response.hovered() || response.dragged(); - let track = Rect::from_center_size( - rect.center(), - Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }), - ); + let active = response.hovered() || response.dragged(); + // Grows rather than jumps when the pointer arrives. + let grow = ui + .ctx() + .animate_bool_with_time(response.id.with("grow"), active, 0.12); + let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow)); + let radius = CornerRadius::same(3); let painter = ui.painter(); - painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52)); + // From the palette rather than a fixed grey: the same bar sits on the + // video's dark scrim and on a light theme's transport strip. + painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35)); - if let Some(progress) = playback.progress() { + let duration = playback.duration.filter(|duration| *duration > 0.0); + let pointer = response + .interact_pointer_pos() + .or_else(|| response.hover_pos()) + .map(|position| ((position.x - track.left()) / track.width()).clamp(0.0, 1.0)); + + // Where the pointer would land, lighter than what has been played: the bar + // shows the jump before it is made. + if let (Some(fraction), true) = (pointer, active) { + let mut ghost = track; + ghost.set_width(track.width() * fraction); + painter.rect_filled(ghost, radius, theme::muted().gamma_multiply(0.45)); + } + + // While dragging, the knob follows the pointer even though the seek waits + // for the release — see below. A knob that stays put under a moving hand + // reads as a bar that is not listening. + let shown = match (response.dragged(), pointer) { + (true, Some(fraction)) => Some(fraction), + _ => playback.progress(), + }; + if let Some(progress) = shown { let mut played = track; played.set_width(track.width() * progress); // The gradient is sized to the *played* part, not to the whole bar, so // the far end of it arrives at the playhead rather than at the end of // the file. A bar that only ever shows the first fifth of its own ramp // does not read as a gradient at all. - theme::accent_fill(painter, played, CornerRadius::same(3), 0.0); + theme::accent_fill(painter, played, radius, 0.0); painter.circle_filled( egui::pos2(played.right(), track.center().y), - if hovered { 8.0 } else { 6.0 }, + 5.0 + 3.0 * grow, Color32::WHITE, ); } // Seeking needs a duration: without one there is nothing for a fraction of // the bar to mean. Before `FileLoaded` mpv has no timeline anyway. - let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else { + let Some(duration) = duration else { return; }; - // Chapter marks, over the bar rather than under it: an opening is two - // minutes of a twenty-four minute file, and the point of the mark is to be - // able to aim just past it. + // Chapter marks, cut into the bar rather than drawn on it: an opening is + // two minutes of a twenty-four minute file, and the point of the mark is to + // be able to aim just past it. for chapter in playback.chapters.iter().skip(1) { let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32; let x = track.left() + track.width() * fraction; painter.rect_filled( Rect::from_min_size( - egui::pos2(x - 1.0, track.top() - 1.0), - Vec2::new(2.0, track.height() + 2.0), + egui::pos2(x - 1.5, track.top() - 1.0), + Vec2::new(3.0, track.height() + 2.0), ), CornerRadius::ZERO, - Color32::from_black_alpha(180), + Color32::from_black_alpha(200), ); } // On release, not on every frame of the drag: each seek cancels outstanding // read-ahead and refetches, so a dragged bar that seeks continuously spends // the whole drag throwing away blocks it just paid for. - if let Some(position) = response.interact_pointer_pos() + if let Some(fraction) = pointer && (response.clicked() || response.drag_stopped()) { - let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0); actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration))); } - if response.hovered() - && let Some(position) = response.hover_pos() - { - let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64; - let at = fraction * duration; + if let (Some(fraction), true) = (pointer, active) { + let at = f64::from(fraction) * duration; // The chapter under the pointer, when there is one: "42:10 · OP" tells // a viewer what they are about to land in, which is the whole reason to // aim at a particular part of the bar. @@ -541,6 +590,25 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec, t ), None => ui::format_time(at), }; - response.clone().on_hover_text_at_pointer(hint); + bubble( + ui, + egui::pos2(track.left() + track.width() * fraction, track.top() - 10.0), + &hint, + ); } } + +/// A small label floating above a point, kept inside the window. +fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) { + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("seek-bubble"), + )); + let galley = painter.layout_no_wrap(text.to_owned(), theme::Role::Label.font(), Color32::WHITE); + let size = galley.size() + Vec2::new(16.0, 8.0); + let screen = ui.ctx().content_rect(); + let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0); + let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size); + painter.rect_filled(frame, CornerRadius::same(6), Color32::from_black_alpha(215)); + painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE); +} diff --git a/crates/pstr-app/src/ui/widgets.rs b/crates/pstr-app/src/ui/widgets.rs new file mode 100644 index 0000000..993f060 --- /dev/null +++ b/crates/pstr-app/src/ui/widgets.rs @@ -0,0 +1,361 @@ +//! Controls egui does not have, or has in a shape that does not fit. +//! +//! Each is painted by hand for the same reason [`super::accent_button`] is: an +//! egui widget takes one flat colour, and what makes these readable is either a +//! gradient or a state drawn as a shape rather than as a fill. + +use egui::{Color32, CornerRadius, Sense, Stroke, Vec2}; + +use crate::theme; + +/// An on/off switch with its label beside it, for settings. +/// +/// Replaces egui's checkbox, whose unchecked box is drawn in the card colour — +/// on a card, which is where every setting lives, an unchecked box was not +/// there at all. A switch says "off" with a shape, not with a fill that has to +/// stand out from what is behind it. +pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response { + const TRACK: Vec2 = Vec2::new(34.0, 20.0); + + let galley = ui.painter().layout_no_wrap( + text.to_owned(), + theme::Role::Body.font(), + Color32::PLACEHOLDER, + ); + let gap = if text.is_empty() { 0.0 } else { 10.0 }; + let size = Vec2::new( + TRACK.x + gap + galley.size().x, + TRACK + .y + .max(galley.size().y) + .max(ui.spacing().interact_size.y), + ); + let (rect, mut response) = ui.allocate_exact_size(size, Sense::click()); + if response.clicked() { + *on = !*on; + response.mark_changed(); + } + response + .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *on, text)); + + if ui.is_rect_visible(rect) { + let ctx = ui.ctx(); + let t = ctx.animate_bool_with_time(response.id, *on, 0.14); + let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); + let track = egui::Rect::from_min_size( + egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0), + TRACK, + ); + let radius = CornerRadius::same((TRACK.y / 2.0) as u8); + let painter = ui.painter(); + + // Off is an outlined track; on is the accent filling it. Both are drawn + // through the switch's travel, so the fill arrives with the knob. + painter.rect_filled(track, radius, theme::card_hover()); + painter.rect_stroke( + track, + radius, + Stroke::new(1.0, theme::muted().gamma_multiply(0.6 + 0.4 * hover)), + egui::StrokeKind::Inside, + ); + if t > 0.0 { + let mut lit = track; + lit.set_width(TRACK.y + (TRACK.x - TRACK.y) * t); + theme::accent_fill(painter, lit, radius, 0.0); + } + + let knob = egui::lerp( + (track.left() + TRACK.y / 2.0)..=(track.right() - TRACK.y / 2.0), + t, + ); + let ink = theme::muted().lerp_to_gamma(Color32::WHITE, t); + painter.circle_filled(egui::pos2(knob, track.center().y), TRACK.y / 2.0 - 3.0, ink); + + if response.has_focus() { + painter.rect_stroke( + track.expand(2.0), + CornerRadius::same((TRACK.y / 2.0 + 2.0) as u8), + Stroke::new(1.5, theme::accent()), + egui::StrokeKind::Outside, + ); + } + + let position = egui::pos2(track.right() + gap, rect.center().y - galley.size().y / 2.0); + painter.galley(position, galley, theme::text()); + } + response.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A round mark that is either a filled tick or an empty ring: "watched". +/// +/// The checkbox it replaced had the same invisible-when-off problem as the one +/// [`toggle`] replaced, and a list of episodes is the one place where the +/// unwatched state is the one that most needs to be seen. +pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response { + const SIZE: f32 = 20.0; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click()); + if ui.is_rect_visible(rect) { + let ctx = ui.ctx(); + let t = ctx.animate_bool_with_time(response.id, watched, 0.14); + let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); + let painter = ui.painter(); + let center = rect.center(); + let radius = SIZE / 2.0 - 1.0; + + if t > 0.0 { + painter.circle_filled(center, radius, theme::accent().gamma_multiply(t)); + } + painter.circle_stroke( + center, + radius, + Stroke::new( + 1.5, + theme::muted() + .lerp_to_gamma(theme::text(), hover) + .gamma_multiply(1.0 - t), + ), + ); + // The tick is drawn in both states: faintly, under the pointer, while + // unwatched — so the mark says what clicking it will do. + let ink = if watched { + theme::on_accent() + } else { + theme::muted().gamma_multiply(hover) + }; + if watched || hover > 0.0 { + let points = [ + center + Vec2::new(-4.5, 0.2), + center + Vec2::new(-1.4, 3.3), + center + Vec2::new(4.6, -3.2), + ]; + painter.line(points.to_vec(), Stroke::new(1.8, ink)); + } + } + response.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A group of settings: a heading, an optional line under it, and a card that +/// holds the rows. +/// +/// The libadwaita preferences pattern, which is what `proton-drive-linux` +/// settles on: one card per subject, one row per setting, the setting's name +/// and what it does on the left and the control on the right — so the eye runs +/// down one edge for names and the other for state. +pub fn settings_group( + ui: &mut egui::Ui, + heading: &str, + description: Option<&str>, + add: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + ui.add_space(4.0); + ui.label( + theme::Role::Subhead + .rich(heading) + .strong() + .color(theme::text()), + ); + if let Some(description) = description { + ui.add_space(2.0); + ui.label(crate::ui::muted(description)); + } + ui.add_space(8.0); + let inner = egui::Frame::new() + .fill(theme::card()) + .corner_radius(CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(16, 6)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 0.0; + add(ui) + }) + .inner; + ui.add_space(26.0); + inner +} + +/// One row of a [`settings_group`]: what it is on the left, the control on the +/// right. Rows after the first get a hairline above them. +pub fn settings_row( + ui: &mut egui::Ui, + title: &str, + subtitle: Option<&str>, + control: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let top = ui.cursor().top(); + // The first row of a card starts at its top margin; anything below that + // has a row above it to be divided from. + if top > ui.max_rect().top() + 1.0 { + let y = top; + ui.painter().hline( + ui.max_rect().x_range(), + y, + Stroke::new(1.0, theme::card_hover()), + ); + } + ui.add_space(10.0); + let inner = ui + .horizontal(|ui| { + ui.set_min_height(28.0); + let text_width = (ui.available_width() * 0.62).max(160.0); + ui.vertical(|ui| { + ui.set_max_width(text_width); + ui.spacing_mut().item_spacing.y = 2.0; + ui.label(theme::Role::Body.rich(title).color(theme::text())); + if let Some(subtitle) = subtitle { + ui.add(egui::Label::new(crate::ui::muted(subtitle)).wrap()); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), control) + .inner + }) + .inner; + ui.add_space(10.0); + inner +} + +/// A row of choices where exactly one is on: pills on a shared track. +/// +/// Returns the one clicked, if it was not already the chosen one. +pub fn segmented( + ui: &mut egui::Ui, + current: T, + choices: &[(T, &str)], +) -> Option { + let mut picked = None; + egui::Frame::new() + .fill(theme::card_hover()) + .corner_radius(CornerRadius::same(8)) + .inner_margin(egui::Margin::same(3)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + for (value, label) in choices { + let selected = *value == current; + let text = theme::Role::Label.rich(*label).color(if selected { + theme::on_accent() + } else { + theme::text() + }); + let button = egui::Button::new(text) + .corner_radius(CornerRadius::same(6)) + .fill(if selected { + theme::accent() + } else { + Color32::TRANSPARENT + }) + .stroke(Stroke::NONE); + if ui.add(button).clicked() && !selected { + picked = Some(*value); + } + } + }); + }); + picked +} + +/// What a [`download_button`] shows. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DownloadGlyph { + /// Not downloaded: an arrow into a tray. + Download, + /// Coming down: a ring filling with how far it has got. + Progress(f32), + /// Stopped part way: the ring, still, with a pause mark. + Paused(f32), + /// Here: a tick. + Done, +} + +/// A round icon button for one file's download. +pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Response { + const SIZE: f32 = 30.0; + let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click()); + if !ui.is_rect_visible(rect) { + return response; + } + let hover = ui + .ctx() + .animate_bool_with_time(response.id, response.hovered(), 0.10); + let painter = ui.painter(); + let c = rect.center(); + if hover > 0.0 { + painter.circle_filled(c, SIZE / 2.0, theme::card_hover().gamma_multiply(hover)); + } + let ink = theme::muted().lerp_to_gamma(theme::text(), hover); + let stroke = Stroke::new(1.6, ink); + let ring = |painter: &egui::Painter, fraction: f32| { + let radius = 9.0; + painter.circle_stroke(c, radius, Stroke::new(2.0, theme::card_hover())); + let steps = 40; + let points: Vec = (0..=((steps as f32 * fraction.clamp(0.0, 1.0)) as usize)) + .map(|step| { + let angle = -std::f32::consts::FRAC_PI_2 + + std::f32::consts::TAU * step as f32 / steps as f32; + c + Vec2::angled(angle) * radius + }) + .collect(); + if points.len() > 1 { + painter.add(egui::Shape::line(points, Stroke::new(2.0, theme::accent()))); + } + }; + match glyph { + DownloadGlyph::Download => { + painter.line_segment([c + Vec2::new(0.0, -6.0), c + Vec2::new(0.0, 3.0)], stroke); + painter.line( + vec![ + c + Vec2::new(-4.0, -1.0), + c + Vec2::new(0.0, 3.0), + c + Vec2::new(4.0, -1.0), + ], + stroke, + ); + painter.line_segment([c + Vec2::new(-6.0, 7.0), c + Vec2::new(6.0, 7.0)], stroke); + } + DownloadGlyph::Progress(fraction) => { + ring(painter, fraction); + // Keeps a frame coming while the ring has somewhere to go. + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(250)); + if hover > 0.0 { + for x in [-2.5, 2.5] { + painter.line_segment( + [c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)], + Stroke::new(1.8, ink.gamma_multiply(hover)), + ); + } + } + } + DownloadGlyph::Paused(fraction) => { + ring(painter, fraction); + for x in [-2.5, 2.5] { + painter.line_segment( + [c + Vec2::new(x, -3.5), c + Vec2::new(x, 3.5)], + Stroke::new(1.8, ink), + ); + } + } + DownloadGlyph::Done => { + painter.line( + vec![ + c + Vec2::new(-5.0, 0.0), + c + Vec2::new(-1.5, 3.5), + c + Vec2::new(5.0, -3.5), + ], + Stroke::new(2.0, theme::accent().lerp_to_gamma(ink, hover)), + ); + } + } + response.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// A small rounded label: a genre, a year, a fact. +pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { + egui::Frame::new() + .fill(theme::card_hover().gamma_multiply(0.8)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(9, 3)) + .show(ui, |ui| { + ui.label(theme::Role::Caption.rich(text).color(theme::text())); + }) + .response +} diff --git a/crates/pstr-app/src/video.rs b/crates/pstr-app/src/video.rs index f0879ff..f1c883d 100644 --- a/crates/pstr-app/src/video.rs +++ b/crates/pstr-app/src/video.rs @@ -67,8 +67,10 @@ pub struct VideoSurface { impl VideoSurface { /// Attach a renderer to `player` on the current OpenGL context. /// - /// `ctx` is woken whenever mpv has a new frame; without that the picture - /// would only advance when the viewer moved the mouse. + /// `wake` is called whenever mpv has a new frame, and should ask the window + /// for one. Without it the picture would only advance when the viewer moved + /// the mouse. The app's goes through [`crate::engine::Engine::wake`], which + /// knows whether the picture is on screen to be drawn at all. /// /// # Safety /// @@ -78,11 +80,11 @@ impl VideoSurface { pub unsafe fn new( player: Arc, gl: Arc, - ctx: egui::Context, + wake: impl Fn() + Send + Sync + 'static, ) -> pstr_player::Result { // SAFETY: forwarded from this function's own contract. let mut renderer = unsafe { VideoRenderer::new(player)? }; - renderer.on_update(move || ctx.request_repaint()); + renderer.on_update(wake); Ok(Self { gl, diff --git a/docs/BUGS.md b/docs/BUGS.md index a0804cd..dfcd451 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -146,6 +146,103 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places). ## Fixed +### B60 — a failed "Add and crawl" left the crawl spinner running forever + +**Symptom.** Pasting a link without its `#` fragment, or one already added, +and pressing *Add and crawl* cleared the form and put "crawling" in the top +bar, where it stayed until the app was restarted. The Refresh button never +came back. The only sign of the real problem was a status line that had +already faded. + +**Cause.** `Action::AddShare` set `crawling` and cleared the form before the +engine had answered. `Engine::add_share` reported a refused link through +`fail`, which sends a plain `Error`, so nothing ever sent `CrawlFinished` for +a crawl that never started. + +**Fix.** The engine answers with `ShareAdded` or `ShareRejected`, and the +form keeps its contents until one of them arrives. A rejection clears +`crawling` and shows its reason inside the form. The link is also checked +while it is typed, using `pstr_core::shares::share_token`, the same check +`ShareStore::add` runs first, so most refusals never reach the engine. + +**Verified.** `ui::shares::tests`: `a_link_without_its_key_is_refused_while_typing` +and `a_share_already_added_is_refused_while_typing`. + +### B59 — every poster fell back to initials after each match run + +**Symptom.** After *Match the library*, and after picking or forgetting a +match by hand, every tile in the grid dropped to its initials and faded the +same picture back in. + +**Cause.** `Event::Metadata` cleared the whole poster cache, because any +title's record might have changed. + +**Fix.** `app::changed_art` compares each title's tile-art URL before and +after, and only those titles are forgotten. A title whose URL did not change +keeps its texture. + +**Verified.** `app::tests::a_match_run_that_changes_nothing_keeps_every_poster` +and `only_titles_whose_art_moved_lose_their_poster`. + +### B58 — "Nothing in the library yet" flashed at every launch + +**Symptom.** For the fraction of a second it took to read the catalog, the +library page showed the empty-library message and its *Add a share* button, +including for a library with hundreds of titles. + +**Cause.** `App::library` starts as `Library::default()`, which is empty, and +the page could not tell "not read yet" from "nothing in it". + +**Fix.** `App::loaded` is set by the first `LibraryLoaded`. Until then the +page draws two rows of blank tiles in the shape of the grid. + +### B57 — subtitles drawn under the player's controls + +**Symptom.** While the controls were up, the bottom subtitle line sat +behind the seek bar and the time labels and could not be read. The controls +are up whenever the mouse moves, which is when someone is watching. + +**Cause.** mpv positions subtitles against the whole window. Nothing told it +that the bottom strip of the window was covered. + +**Fix.** `Playback::lift_subtitles` sets mpv's `sub-pos` from the measured +height of the controls while they are visible, and back to 100 when they +hide. It sends a command only when the value changes. `sub-pos` rather than a +margin, because it also moves ASS subtitles. + +### B56 — horizontal lines across the player's scrims + +**Symptom.** Thin dark lines ran across the picture in the fade under the +title strip and the fade above the controls. + +**Cause.** Each scrim was a stack of flat bands, each one pixel taller than +its step so no gap could open between them. Every overlap was a line drawn +at twice the alpha. + +**Fix.** `ui::player::fade` draws one mesh with a colour per row of vertices, +and the GPU interpolates between them. There are no overlaps left to show. + +### B55 — every newly fetched block waited for its own fsync before playback + +**Symptom.** Not visible as a bug. Every block the player had to wait for +from the network, which means the first frame and the block after every +seek, also waited for a 4 MiB write to the block cache, an fsync of it, and an +fsync of its sidecar. + +**Cause.** `block_at` awaited `DiskCache::put` before returning the block, and +`write_entry` synced both files. + +**Fix.** The put runs in a spawned task behind the read. The sidecar is no +longer synced: the ordering the module note describes depends only on the +block's sync, and a sidecar lost in a crash only turns a valid entry into a +miss. Downloads now open their streams with `StreamSource::open_for_copy`, +which reads the disk cache but never writes to it. A download no longer +pushes the episode just watched out of the cache to store bytes that are about +to be on disk anyway. + +**Verified.** `stream::tests::a_fetched_block_reaches_the_disk_cache_without_the_read_waiting_for_it` +and `a_copying_stream_reads_the_disk_cache_but_never_adds_to_it`. + ### B54 — a window on another workspace was declared "not responding" **Symptom.** On Hyprland, switching to another workspace while `proton-stream` diff --git a/docs/DESIGN-TOKENS.md b/docs/DESIGN-TOKENS.md index 8b9f146..0b553c1 100644 --- a/docs/DESIGN-TOKENS.md +++ b/docs/DESIGN-TOKENS.md @@ -101,6 +101,24 @@ for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.2 for artwork arriving. Compose's defaults are close enough that nothing here overrides them. +## Desktop widgets + +`pstr-app/src/ui/widgets.rs` holds the hand-painted controls. Use them instead +of egui's own in new code: + +- `toggle`: an on/off switch. It replaces `ui.checkbox`, whose unchecked box + is filled with the card colour and so disappears on a card. +- `settings_group` / `settings_row`: one card per subject, with the name and + description on the left and the control on the right (the libadwaita + preferences pattern `proton-drive-linux` uses). +- `segmented`: pills on a shared track, for picking one of a few values + (library filter, season). +- `chip`: a small rounded fact, such as a genre. +- `watched_mark`, `download_button`: the round icon buttons on an episode row. + +Messages go through `ui::toast::Toasts` (via `App::note`), not a status line. +Destructive actions ask through `ui::confirm`. + ## Adding a token Name it in the owning client first, restate it on the other side, and extend the