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
This commit is contained in:
nvrl committed 2026-10-02 20:16:27 +02:00
1 parent 41d1fbf073
commit c5808a5e50
6 files changed
+144 -12

No files matched your search

+3 -1
View File
@@ -478,10 +478,12 @@ impl Engine {
/// Send an event and wake the UI. A closed channel means the window is /// Send an event and wake the UI. A closed channel means the window is
/// gone, which is not an error worth propagating anywhere. /// gone, which is not an error worth propagating anywhere.
pub(crate) fn emit(&self, event: Event) { pub(crate) fn emit(&self, event: Event) {
// The two that arrive on mpv's clock rather than on anything happening.
let ticking = matches!( let ticking = matches!(
event, event,
Event::Player { Event::Player {
event: pstr_player::PlayerEvent::Position(_), event: pstr_player::PlayerEvent::Position(_)
| pstr_player::PlayerEvent::Buffered(_),
.. ..
} }
); );
+8
View File
@@ -189,6 +189,10 @@ pub struct Playback {
/// A seek was issued and the picture has not come back yet — the thing /// A seek was issued and the picture has not come back yet — the thing
/// worth showing a spinner for on a link this slow. /// worth showing a spinner for on a link this slow.
pub seeking: bool, pub seeking: bool,
/// The end of what mpv has read ahead, in seconds from the start.
pub buffered: Option<f64>,
/// Playback is held waiting for data.
pub buffering: bool,
} }
impl Playback { impl Playback {
@@ -253,6 +257,8 @@ impl Playback {
duration: None, duration: None,
loaded: false, loaded: false,
seeking: false, seeking: false,
buffered: None,
buffering: false,
target, target,
}) })
} }
@@ -358,6 +364,8 @@ impl Playback {
PlayerEvent::Paused(paused) => self.paused = *paused, PlayerEvent::Paused(paused) => self.paused = *paused,
PlayerEvent::Seek => self.seeking = true, PlayerEvent::Seek => self.seeking = true,
PlayerEvent::PlaybackRestart => self.seeking = false, PlayerEvent::PlaybackRestart => self.seeking = false,
PlayerEvent::Buffered(end) => self.buffered = Some(*end),
PlayerEvent::Buffering(held) => self.buffering = *held,
_ => {} _ => {}
} }
} }
+91 -8
View File
@@ -3,6 +3,7 @@
use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Filter, LibraryView, Page, Sort}; use crate::app::{Action, Filter, LibraryView, Page, Sort};
use crate::playback::PlaybackTarget;
use crate::theme; use crate::theme;
use crate::ui::Art; use crate::ui::Art;
use crate::ui::{self, Card}; use crate::ui::{self, Card};
@@ -156,7 +157,7 @@ fn continue_row(
}) })
.unwrap_or_else(|| episode.label()); .unwrap_or_else(|| episode.label());
let clicked = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: art.of(title),
@@ -168,10 +169,10 @@ fn continue_row(
// to reach, so nothing to flex to. // to reach, so nothing to flex to.
width: theme::CARD_WIDTH, 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()))); 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. // the left edge.
ui.spacing_mut().item_spacing.x = theme::CARD_GAP; ui.spacing_mut().item_spacing.x = theme::CARD_GAP;
for title in row { for title in row {
let clicked = ui::card( let response = ui::card(
ui, ui,
Card { Card {
art: art.of(title), art: art.of(title),
@@ -217,10 +218,10 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
badge: None, badge: None,
width: grid.width, width: grid.width,
}, },
) );
.clicked(); tile_menu(&response, title, false, actions);
if clicked { if response.clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone()))); 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<Action>,
) {
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 /// The grey line under a card: what it is, how much there is, and how much is
/// left. /// left.
/// ///
+14
View File
@@ -206,6 +206,8 @@ pub fn show(
if !picture { if !picture {
waiting(ui, playback, rect); 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 // 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. /// 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 /// It keeps a way out, because opening a file over a public link can take
+17 -3
View File
@@ -81,9 +81,9 @@ pub fn full(
}); });
ui.add_space(12.0); 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.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded { ui.label(ui::muted(if playback.loaded && playback.seeking {
"seeking" "seeking"
} else { } else {
"buffering" "buffering"
@@ -170,7 +170,7 @@ pub fn mini(
ui.add_space(6.0); ui.add_space(6.0);
volume(ui, playback, actions); volume(ui, playback, actions);
if playback.seeking || !playback.loaded { if playback.seeking || playback.buffering || !playback.loaded {
ui.add_space(6.0); ui.add_space(6.0);
ui.add(egui::Spinner::new().size(14.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<Action>, t
painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35)); painter.rect_filled(track, radius, theme::muted().gamma_multiply(0.35));
let duration = playback.duration.filter(|duration| *duration > 0.0); 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 let pointer = response
.interact_pointer_pos() .interact_pointer_pos()
.or_else(|| response.hover_pos()) .or_else(|| response.hover_pos())
+11
View File
@@ -94,6 +94,11 @@ pub enum PlayerEvent {
/// Output volume, 0–100. /// Output volume, 0–100.
Volume(f64), Volume(f64),
Muted(bool), 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. /// 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 /// 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. // it clamps a restored value to.
self.mpv.observe_property("volume", Format::Double, 0)?; self.mpv.observe_property("volume", Format::Double, 0)?;
self.mpv.observe_property("mute", Format::Flag, 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(()) Ok(())
} }
@@ -523,6 +532,8 @@ fn translate(event: Event<'_>) -> PlayerEvent {
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value), ("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value), ("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(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,
}, },
_ => PlayerEvent::Other, _ => PlayerEvent::Other,