From c5808a5e504f3946025e890ddf5ab91a211ca66f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 19:39:26 +0200 Subject: [PATCH] feat(app,player): tile context menu, buffered range on the seek bar, stall spinner - right-click a library tile to play or resume it, mark it watched or unwatched, download it, change its match, or take it off Continue watching - mpv's demuxer-cache-time is shaded on the seek bar, so it shows whether a jump lands in what is already read ahead - paused-for-cache shows a spinner over the picture and in the controls --- crates/pstr-app/src/engine.rs | 4 +- crates/pstr-app/src/playback.rs | 8 +++ crates/pstr-app/src/ui/library.rs | 99 ++++++++++++++++++++++++++--- crates/pstr-app/src/ui/player.rs | 14 ++++ crates/pstr-app/src/ui/transport.rs | 20 +++++- crates/pstr-player/src/player.rs | 11 ++++ 6 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 5dec5ae..b16882e 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -478,10 +478,12 @@ 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) { + // The two that arrive on mpv's clock rather than on anything happening. let ticking = matches!( event, Event::Player { - event: pstr_player::PlayerEvent::Position(_), + event: pstr_player::PlayerEvent::Position(_) + | pstr_player::PlayerEvent::Buffered(_), .. } ); diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 8b8d419..66290e1 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -189,6 +189,10 @@ pub struct Playback { /// A seek was issued and the picture has not come back yet — the thing /// worth showing a spinner for on a link this slow. pub seeking: bool, + /// The end of what mpv has read ahead, in seconds from the start. + pub buffered: Option, + /// Playback is held waiting for data. + pub buffering: bool, } impl Playback { @@ -253,6 +257,8 @@ impl Playback { duration: None, loaded: false, seeking: false, + buffered: None, + buffering: false, target, }) } @@ -358,6 +364,8 @@ impl Playback { PlayerEvent::Paused(paused) => self.paused = *paused, PlayerEvent::Seek => self.seeking = true, PlayerEvent::PlaybackRestart => self.seeking = false, + PlayerEvent::Buffered(end) => self.buffered = Some(*end), + PlayerEvent::Buffering(held) => self.buffering = *held, _ => {} } } diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 140870a..7e1c67d 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -3,6 +3,7 @@ use pstr_core::library::{Library, Title, TitleKind}; use crate::app::{Action, Filter, LibraryView, Page, Sort}; +use crate::playback::PlaybackTarget; use crate::theme; use crate::ui::Art; use crate::ui::{self, Card}; @@ -156,7 +157,7 @@ fn continue_row( }) .unwrap_or_else(|| episode.label()); - let clicked = ui::card( + let response = ui::card( ui, Card { art: art.of(title), @@ -168,10 +169,10 @@ fn continue_row( // to reach, so nothing to flex to. width: theme::CARD_WIDTH, }, - ) - .clicked(); + ); + tile_menu(&response, title, true, actions); - if clicked { + if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } @@ -205,7 +206,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V // the left edge. ui.spacing_mut().item_spacing.x = theme::CARD_GAP; for title in row { - let clicked = ui::card( + let response = ui::card( ui, Card { art: art.of(title), @@ -217,10 +218,10 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V badge: None, width: grid.width, }, - ) - .clicked(); + ); + tile_menu(&response, title, false, actions); - if clicked { + if response.clicked() { actions.push(Action::Goto(Page::Title(title.key.clone()))); } } @@ -229,6 +230,88 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V } } +/// What a right-click on a tile offers: the things otherwise one page away. +/// +/// `continuing` is the Continue watching shelf, where the tile can also be +/// taken off the shelf. +fn tile_menu( + response: &egui::Response, + title: &Title, + continuing: bool, + actions: &mut Vec, +) { + response.context_menu(|ui| { + if let Some(episode) = title.next_up() { + let label = match (title.resume(), episode.numbering()) { + (Some(_), Some(numbering)) => format!("Resume {numbering}"), + (Some(_), None) => "Resume".to_owned(), + (None, _) => "Play".to_owned(), + }; + if ui.button(label).clicked() { + actions.push(Action::Play(PlaybackTarget::new(title, episode))); + } + } + if ui.button("Open").clicked() { + actions.push(Action::Goto(Page::Title(title.key.clone()))); + } + ui.separator(); + + let all_watched = title.watched_count() == title.episode_count(); + if ui + .button(if all_watched { + "Mark unwatched" + } else { + "Mark watched" + }) + .clicked() + { + for episode in title.episodes() { + if episode.is_watched() == all_watched { + actions.push(Action::SetWatched { + share_id: episode.node.share_id.clone(), + link_id: episode.node.link_id.clone(), + watched: !all_watched, + duration: episode.watch.and_then(|watch| watch.duration_secs), + }); + } + } + } + if continuing + && let Some(episode) = title.resume() + && ui + .button("Remove from Continue watching") + .on_hover_text("Forget where you stopped in it") + .clicked() + { + actions.push(Action::SetWatched { + share_id: episode.node.share_id.clone(), + link_id: episode.node.link_id.clone(), + watched: false, + duration: episode.watch.and_then(|watch| watch.duration_secs), + }); + } + + let count = title.episode_count(); + let download = if count == 1 { + "Download".to_owned() + } else { + format!("Download all {count}") + }; + if ui.button(download).clicked() { + actions.push(Action::MakeOffline( + title + .episodes() + .map(|episode| PlaybackTarget::new(title, episode)) + .collect(), + )); + } + ui.separator(); + if ui.button("Change match…").clicked() { + actions.push(Action::OpenMatcher(title.key.clone())); + } + }); +} + /// The grey line under a card: what it is, how much there is, and how much is /// left. /// diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index 73900a8..a56b7b5 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -206,6 +206,8 @@ pub fn show( if !picture { waiting(ui, playback, rect); + } else if playback.buffering || playback.seeking { + stalled(ui, rect); } // Click anywhere on the picture to pause, which is the one control that @@ -329,6 +331,18 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) { ); } +/// A spinner over a picture held waiting for data. Without it, a frozen frame +/// with the controls hidden looks the same as a hang. +fn stalled(ui: &mut egui::Ui, rect: Rect) { + let disc = Rect::from_center_size(rect.center(), Vec2::splat(64.0)); + ui.painter() + .circle_filled(disc.center(), 32.0, Color32::from_black_alpha(140)); + ui.put( + Rect::from_center_size(disc.center(), Vec2::splat(36.0)), + egui::Spinner::new().size(36.0).color(Color32::WHITE), + ); +} + /// The screen between two episodes: one has ended and the next is opening. /// /// It keeps a way out, because opening a file over a public link can take diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index 9ac69d6..b402ada 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -81,9 +81,9 @@ pub fn full( }); ui.add_space(12.0); - if playback.seeking || !playback.loaded { + if playback.seeking || playback.buffering || !playback.loaded { ui.add(egui::Spinner::new().size(16.0)); - ui.label(ui::muted(if playback.loaded { + ui.label(ui::muted(if playback.loaded && playback.seeking { "seeking" } else { "buffering" @@ -170,7 +170,7 @@ pub fn mini( ui.add_space(6.0); volume(ui, playback, actions); - if playback.seeking || !playback.loaded { + if playback.seeking || playback.buffering || !playback.loaded { ui.add_space(6.0); ui.add(egui::Spinner::new().size(14.0)); } @@ -511,6 +511,20 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec, t painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35)); let duration = playback.duration.filter(|duration| *duration > 0.0); + // What is already read ahead, a shade lighter than the empty track: on a + // link this slow, whether a jump lands in it is the difference between an + // instant seek and a fetch. + if let (Some(duration), Some(end)) = (duration, playback.buffered) + && end > playback.position + { + let from = (playback.position / duration).clamp(0.0, 1.0) as f32; + let to = (end / duration).clamp(0.0, 1.0) as f32; + let buffered = Rect::from_min_max( + egui::pos2(track.left() + track.width() * from, track.top()), + egui::pos2(track.left() + track.width() * to, track.bottom()), + ); + painter.rect_filled(buffered, radius, theme::muted().gamma_multiply(0.30)); + } let pointer = response .interact_pointer_pos() .or_else(|| response.hover_pos()) diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs index 4930e0c..0c478f8 100644 --- a/crates/pstr-player/src/player.rs +++ b/crates/pstr-player/src/player.rs @@ -94,6 +94,11 @@ pub enum PlayerEvent { /// Output volume, 0–100. Volume(f64), Muted(bool), + /// How far into the file the demuxer has read ahead, in seconds from the + /// start — the end of what can play without another fetch. + Buffered(f64), + /// Whether playback is held waiting for data. + Buffering(bool), /// What the file contains and what is playing of it. /// /// Not an mpv event: mpv reports a track list as a node property, and this @@ -308,6 +313,10 @@ impl Player { // it clamps a restored value to. self.mpv.observe_property("volume", Format::Double, 0)?; self.mpv.observe_property("mute", Format::Flag, 0)?; + self.mpv + .observe_property("demuxer-cache-time", Format::Double, 0)?; + self.mpv + .observe_property("paused-for-cache", Format::Flag, 0)?; Ok(()) } @@ -523,6 +532,8 @@ fn translate(event: Event<'_>) -> PlayerEvent { ("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value), ("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value), ("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value), + ("demuxer-cache-time", PropertyData::Double(value)) => PlayerEvent::Buffered(value), + ("paused-for-cache", PropertyData::Flag(value)) => PlayerEvent::Buffering(value), _ => PlayerEvent::Other, }, _ => PlayerEvent::Other,