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
|
/// 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(_),
|
||||||
..
|
..
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user