mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
41d1fbf073
commit
c5808a5e50
6 files changed
+144
-12
No files matched your search
@@ -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(_),
|
||||
..
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
/// left.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Action>, 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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user