feat(app): player side panel, seek HUD and pause screen

The audio, subtitle, speed and chapter pickers were stock popup menus
hanging off the transport. They are now panels down the right of the
picture, in the place and style of the episode list: one at a time, a
whole row per choice, the chosen one filled and ticked, closed by its
button again or by Esc. The library's mini player keeps its menus.

What a key did is shown in the middle of the picture with an icon, and
the volume with a bar. Seeks the same way in quick succession add up, so
three presses of the right arrow read "+ 90 s".

After five seconds paused with the mouse still, the picture dims and the
controls give way to what is playing: the logo or the name, the episode,
and its synopsis, or the show's when the episode has none. Moving the
mouse brings the controls back.
This commit is contained in:
nvrl committed 2026-10-31 02:52:55 +01:00
1 parent a3bc9023c7
commit 466630a9ba
5 files changed
+784 -241

No files matched your search

+106 -40
View File
@@ -281,8 +281,8 @@ pub enum Action {
share_id: String,
link_id: String,
},
/// Open or close the list of episodes beside the picture.
ToggleEpisodes,
/// Open or close one of the panels beside the picture.
TogglePanel(ui::player::PlayerPanel),
/// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache,
@@ -712,7 +712,7 @@ pub struct App {
/// 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)>,
osd: Option<ui::player::Flash>,
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".
@@ -733,8 +733,8 @@ pub struct App {
auto_skipped: (u64, Vec<i64>),
/// The subtitle style changed and has not been written yet.
subtitle_style_unsaved: bool,
/// Whether the episode list is out on the player page.
pub episode_drawer: bool,
/// Which panel is out beside the picture on the player page.
pub player_panel: Option<ui::player::PlayerPanel>,
/// 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,
@@ -844,7 +844,7 @@ impl App {
auto_skipped: (0, Vec::new()),
subtitle_style_unsaved: false,
fullscreen: false,
episode_drawer: false,
player_panel: None,
opening: None,
connecting: true,
crawling: false,
@@ -869,9 +869,26 @@ impl App {
/// 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<String>) {
fn flash(&mut self, ctx: &egui::Context, icon: &'static str, text: impl Into<String>) {
self.flash_level(ctx, icon, text, None);
}
/// The same, with a level drawn under it.
fn flash_level(
&mut self,
ctx: &egui::Context,
icon: &'static str,
text: impl Into<String>,
level: Option<f32>,
) {
if self.page == Page::Player {
self.osd = Some((text.into(), ctx.input(|input| input.time)));
self.osd = Some(ui::player::Flash {
icon,
text: text.into(),
level,
seek: None,
at: ctx.input(|input| input.time),
});
}
}
@@ -1821,7 +1838,7 @@ impl App {
playback.position,
));
self.send_player(crate::playback::Command::Screenshot(path));
self.flash(ctx, "screenshot saved".to_owned());
self.flash(ctx, egui_phosphor::regular::CAMERA, "Screenshot saved");
}
Action::PasteShare(url) => {
self.page = Page::Shares;
@@ -1911,15 +1928,11 @@ impl App {
);
}
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 crate::playback::Command::SeekBy(seconds) = command
&& self.page == Page::Player
{
let now = ctx.input(|input| input.time);
self.osd = Some(ui::player::Flash::seek(self.osd.as_ref(), seconds, now));
}
if let Some(playback) = &self.playback {
playback.send(command);
@@ -1947,7 +1960,7 @@ impl App {
}
let text = format!("Speed {}", ui::format_speed(speed));
if self.page == Page::Player {
self.flash(ctx, text);
self.flash(ctx, egui_phosphor::regular::GAUGE, text);
} else {
self.note(ctx, text, false);
}
@@ -2032,7 +2045,9 @@ impl App {
Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history");
}
Action::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
Action::TogglePanel(panel) => {
self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
}
Action::RestoreWatch(states) => {
for (share_id, link_id, state) in states {
self.engine.save_watch_state(share_id, link_id, state);
@@ -2077,13 +2092,13 @@ impl App {
"Subtitle delay {}",
ui::format_delay(playback.subtitle_delay)
);
self.flash(ctx, text);
self.flash(ctx, egui_phosphor::regular::SUBTITLES, text);
}
}
Action::ResetSubtitleDelay => {
if let Some(playback) = &mut self.playback {
playback.set_subtitle_delay(0.0);
self.flash(ctx, "Subtitle delay 0 s");
self.flash(ctx, egui_phosphor::regular::SUBTITLES, "Subtitle delay 0 s");
}
}
Action::SetAppearance(appearance) => {
@@ -2094,7 +2109,18 @@ impl App {
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}%"));
self.flash_level(
ctx,
if volume <= 0.0 {
egui_phosphor::regular::SPEAKER_SLASH
} else if volume < 50.0 {
egui_phosphor::regular::SPEAKER_LOW
} else {
egui_phosphor::regular::SPEAKER_HIGH
},
format!("Volume {volume:.0}%"),
Some((volume / pstr_player::MAX_VOLUME) as f32),
);
}
}
Action::ToggleMute => {
@@ -2107,7 +2133,11 @@ impl App {
None => !prefs.muted,
};
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" });
if prefs.muted {
self.flash(ctx, egui_phosphor::regular::SPEAKER_SLASH, "Muted");
} else {
self.flash(ctx, egui_phosphor::regular::SPEAKER_HIGH, "Sound on");
}
self.engine.set_playback_prefs(prefs, true);
}
Action::SelectTrack { kind, id } => {
@@ -2323,13 +2353,7 @@ impl eframe::App for App {
speed: playback.speed,
skip_to: playback.skippable().map(|(_, end)| end),
});
shortcuts(
ctx,
self.fullscreen,
self.episode_drawer,
state,
&mut actions,
);
shortcuts(ctx, self.fullscreen, self.player_panel, 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.
@@ -2345,7 +2369,7 @@ impl eframe::App for App {
posters,
metadata,
episodes,
episode_drawer,
player_panel,
..
} = self;
// Before the list borrows the art caches for the rest of the frame.
@@ -2369,6 +2393,45 @@ impl eframe::App for App {
next: opening.from == Page::Player,
}
});
// Only while paused, which is the only time the pause screen
// can show: the logo is a texture lookup, and could be a fetch.
let about = playback
.as_ref()
.filter(|playback| playback.paused)
.and_then(|playback| {
let title = library.get(&playback.target.title_key)?;
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
names: engine.appearance().names,
};
let target = &playback.target;
let episode = title
.seasons
.iter()
.flat_map(|season| &season.episodes)
.find(|episode| {
episode.node.share_id == target.share_id
&& episode.node.link_id == target.link_id
});
let synopsis = episode
.and_then(|episode| art.episode(&title.key, episode))
.and_then(|found| found.overview.clone())
.or_else(|| {
art.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.overview.clone())
});
Some(ui::player::About {
name: art.name(title).to_owned(),
logo: art.logo(title),
synopsis,
})
});
let list = playback
.as_ref()
.and_then(|playback| library.get(&playback.target.title_key))
@@ -2383,7 +2446,6 @@ impl eframe::App for App {
episodes,
names: engine.appearance().names,
},
open: *episode_drawer,
});
// No panels: the controls are drawn over the picture, and a nav bar
// above a film is the one thing every player agrees not to do.
@@ -2400,12 +2462,14 @@ impl eframe::App for App {
up_next,
neighbours,
episodes: list,
panel: *player_panel,
about,
},
&mut actions,
);
});
if let Some((text, at)) = &self.osd
&& !ui::player::osd(ctx, text, *at)
if let Some(flash) = &self.osd
&& !flash.show(ctx, ctx.content_rect())
{
self.osd = None;
}
@@ -3069,7 +3133,7 @@ struct PlayerKeys {
/// 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];
use crate::ui::transport::SPEEDS;
/// The next step from `speed` in `direction`, from wherever it is now.
fn step_speed(speed: f64, faster: bool) -> f64 {
@@ -3092,7 +3156,7 @@ fn step_speed(speed: f64, faster: bool) -> f64 {
fn shortcuts(
ctx: &egui::Context,
fullscreen: bool,
drawer: bool,
panel: Option<ui::player::PlayerPanel>,
playing: Option<PlayerKeys>,
actions: &mut Vec<Action>,
) {
@@ -3171,9 +3235,11 @@ fn shortcuts(
// mpv's own keys for it, and the step mpv uses.
Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)),
Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)),
Key::E => actions.push(Action::ToggleEpisodes),
// The episode list is the innermost thing open, so it goes first.
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
Key::E => actions.push(Action::TogglePanel(ui::player::PlayerPanel::Episodes)),
// A panel is the innermost thing open, so it goes first.
Key::Escape if panel.is_some() => {
actions.push(Action::TogglePanel(panel.expect("checked above")));
}
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
Key::Escape => actions.push(Action::LeavePlayer),
_ => {}
+597 -143
View File
@@ -72,6 +72,53 @@ const DRAWER_WIDTH: f32 = 380.0;
/// The stills in the episode list.
const DRAWER_STILL: Vec2 = Vec2::new(112.0, 63.0);
/// How long the picture has to sit paused, with the mouse still, before the
/// controls give way to what is playing. Long enough that a pause to answer
/// the door is not one, short enough that coming back to it finds the name.
const RESTING_SECONDS: f64 = 5.0;
/// How long the pause screen takes to settle over the picture: slower than
/// the controls fade, because nobody is waiting on it.
const REST_FADE: f32 = 0.6;
/// The panels that open down the right of the picture. One at a time: each is
/// a list read at leisure, and two of them side by side would cover the film.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayerPanel {
Episodes,
/// Audio and subtitles, and the subtitles' size, place and timing.
Tracks,
Speed,
Chapters,
}
impl PlayerPanel {
fn heading(self) -> &'static str {
match self {
Self::Episodes => "Episodes",
Self::Tracks => "Audio and subtitles",
Self::Speed => "Playback speed",
Self::Chapters => "Chapters",
}
}
}
/// Whether the title has an episode panel to offer, and which panel is out.
#[derive(Debug, Clone, Copy)]
struct Panels {
episodes: bool,
open: Option<PlayerPanel>,
}
/// What the pause screen says about what is playing.
pub struct About {
/// The title's name, as the library shows it.
pub name: String,
/// The title's logo, when its provider has one; its name otherwise.
pub logo: Option<egui::TextureHandle>,
/// What this episode is about, or failing that the whole title.
pub synopsis: Option<String>,
}
/// State the page keeps between frames.
///
/// Held by the app and passed in by reference, because one thing here *is*
@@ -89,6 +136,9 @@ pub struct Overlay {
/// How tall the controls were the last time they were drawn, padding
/// included. Measured rather than assumed — see [`CHROME_PAD_X`].
chrome_height: f32,
/// When the picture was last seen to pause, on egui's clock, for the
/// pause screen. `None` while it plays.
paused_since: Option<f64>,
}
impl Default for Overlay {
@@ -97,6 +147,7 @@ impl Default for Overlay {
last_movement: None,
last_position: None,
chrome_height: CHROME_HEIGHT_GUESS + CHROME_PAD_TOP + CHROME_PAD_BOTTOM,
paused_since: None,
}
}
}
@@ -114,17 +165,19 @@ pub struct Chrome<'a> {
pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours,
/// The title's episodes, when it has more than one — what the list down
/// the right of the picture shows.
/// The title's episodes, when it has more than one — what the episode
/// panel shows.
pub episodes: Option<Episodes<'a>>,
/// Which panel is out down the right of the picture, if any.
pub panel: Option<PlayerPanel>,
/// What the pause screen shows, while the picture is paused.
pub about: Option<About>,
}
/// The playing title's episodes, for the list beside the picture.
pub struct Episodes<'a> {
pub title: &'a pstr_core::library::Title,
pub art: ui::Art<'a>,
/// Whether the list is out.
pub open: bool,
}
/// What the end-of-episode card says, when there is one.
@@ -187,6 +240,25 @@ impl Overlay {
};
ctx.input(|input| input.time) - last_movement < IDLE_SECONDS
}
/// Whether the picture has sat paused long enough, with nobody touching
/// the mouse, for the pause screen to take over from the controls.
fn resting(&mut self, ctx: &egui::Context, playback: &Playback) -> bool {
let now = ctx.input(|input| input.time);
if !playback.paused || !playback.loaded {
self.paused_since = None;
return false;
}
let since = *self.paused_since.get_or_insert(now);
let still = since.max(self.last_movement.unwrap_or(now));
let left = RESTING_SECONDS - (now - still);
if left > 0.0 {
// Nothing else causes a frame while a paused picture sits there.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(left));
return false;
}
true
}
}
/// What the player page shows while a file opens and there is no picture yet.
@@ -219,6 +291,8 @@ pub fn show(
up_next,
neighbours,
mut episodes,
panel,
about,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
@@ -260,10 +334,21 @@ pub fn show(
actions.push(Action::ToggleFullscreen);
}
let drawer_open = episodes.as_ref().is_some_and(|list| list.open);
// The controls stay up while the list is out: it hangs off the button in
// the title strip, and is read at leisure.
let visible = overlay.visible(&ctx, playback) || drawer_open;
// The episode panel needs episodes; anything else asked for opens as is.
let panel = panel.filter(|panel| *panel != PlayerPanel::Episodes || episodes.is_some());
if panel.is_some() {
// Reading a panel is using the player: closing it should find the
// controls, not a pause screen that was waiting behind it.
overlay.last_movement = Some(ctx.input(|input| input.time));
}
let resting = panel.is_none() && overlay.resting(&ctx, playback);
let rest = ctx.animate_bool_with_time(ui.id().with("resting"), resting, REST_FADE);
if rest > 0.0 {
pause_screen(ui, playback, about.as_ref(), rect, rest);
}
// The controls stay up while a panel is out: it hangs off a button in
// them, and is read at leisure. They give way to the pause screen.
let visible = (overlay.visible(&ctx, playback) && !resting) || panel.is_some();
// Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys.
let float_above = if visible {
@@ -295,7 +380,10 @@ pub fn show(
ui,
playback,
neighbours,
episodes.as_ref().map(|list| list.open),
Panels {
episodes: episodes.is_some(),
open: panel,
},
rect,
overlay.chrome_height,
actions,
@@ -303,9 +391,23 @@ pub fn show(
})
.inner;
}
if let Some(list) = episodes.as_mut().filter(|list| list.open) {
episode_drawer(&ctx, list, playback, rect, overlay.chrome_height, actions);
} else {
if let Some(panel) = panel {
side_panel(
&ctx,
panel,
rect,
overlay.chrome_height,
actions,
|ui, actions| match (panel, episodes.as_mut()) {
(PlayerPanel::Episodes, Some(list)) => episode_list(ui, list, playback, actions),
(PlayerPanel::Tracks, _) => track_list(ui, playback, actions),
(PlayerPanel::Speed, _) => speed_list(ui, playback, actions),
(PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions),
(PlayerPanel::Episodes, None) => {}
},
);
}
if panel != Some(PlayerPanel::Episodes) {
ctx.data_mut(|data| data.remove::<String>(egui::Id::new(DRAWER_SCROLLED)));
}
if !visible {
@@ -695,7 +797,7 @@ fn chrome(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
drawer: Option<bool>,
panels: Panels,
rect: Rect,
previous_height: f32,
actions: &mut Vec<Action>,
@@ -748,18 +850,18 @@ fn chrome(
{
actions.push(Action::ToggleFullscreen);
}
if let Some(open) = drawer {
if panels.episodes {
ui.add_space(theme::space::S);
// In the accent while the drawer is out, so the button
// In the accent while the list is out, so the button
// that closes it is the one that says it is open.
let icon = egui_phosphor::regular::LIST_BULLETS;
let button = if open {
let button = if panels.open == Some(PlayerPanel::Episodes) {
ui::accent_button(ui, icon)
} else {
ui::glass_button(ui, icon)
};
if button.hint("Episodes (E)").clicked() {
actions.push(Action::ToggleEpisodes);
actions.push(Action::TogglePanel(PlayerPanel::Episodes));
}
}
// Esc leaves the page and keeps playing; this is the one-step
@@ -800,7 +902,7 @@ fn chrome(
.max_rect(content)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui::transport::full(ui, playback, neighbours, actions);
ui::transport::full(ui, playback, neighbours, panels.open, actions);
ui.min_rect().height()
},
)
@@ -813,15 +915,15 @@ fn chrome(
/// scrolls to the playing one once when it opens rather than every frame.
const DRAWER_SCROLLED: &str = "episode-drawer-scrolled";
/// The title's episodes, down the right of the picture between the title
/// strip and the controls. A season at a time, opened on the playing one.
fn episode_drawer(
/// A panel down the right of the picture, between the title strip and the
/// controls: a heading, a way to close it, and `add` under them.
fn side_panel(
ctx: &egui::Context,
list: &mut Episodes<'_>,
playback: &Playback,
panel: PlayerPanel,
rect: Rect,
chrome_height: f32,
actions: &mut Vec<Action>,
add: impl FnOnce(&mut egui::Ui, &mut Vec<Action>),
) {
let width = DRAWER_WIDTH.min(rect.width() * 0.45);
let area = Rect::from_min_max(
@@ -831,6 +933,216 @@ fn episode_drawer(
if area.height() < 160.0 {
return;
}
let margin = theme::space::L;
egui::Area::new(egui::Id::new("player-panel"))
.order(egui::Order::Foreground)
.fixed_pos(area.min)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::surface().gamma_multiply(0.96))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(margin as i8))
.shadow(theme::tile_shadow(1.0))
.show(ui, |ui| {
ui.set_width(area.width() - 2.0 * margin);
ui.set_height(area.height() - 2.0 * margin);
ui.horizontal(|ui| {
ui.label(
theme::Role::Heading
.rich(panel.heading())
.color(theme::text()),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.hint("Close (Esc)")
.clicked()
{
actions.push(Action::TogglePanel(panel));
}
});
});
ui.add_space(theme::space::S);
add(ui, actions);
});
});
}
/// One choice in a panel: the whole row is the button, and the chosen one is
/// filled and ticked, so it reads from across the room.
fn option_row(
ui: &mut egui::Ui,
selected: bool,
text: &str,
detail: Option<&str>,
) -> egui::Response {
let (rect, response) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 36.0), Sense::click());
let hover = ui.ctx().animate_bool_with_time(
response.id.with("hover"),
response.hovered(),
theme::motion::HOVER,
);
let painter = ui.painter();
let radius = CornerRadius::same(theme::radius::MD);
if selected {
painter.rect_filled(rect, radius, theme::accent_dim());
} else if hover > 0.0 {
painter.rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover));
}
let mut left = rect.left() + theme::space::L;
if let Some(detail) = detail {
let galley =
painter.layout_no_wrap(detail.to_owned(), theme::Role::Label.font(), theme::muted());
let width = galley.size().x;
painter.galley(
egui::pos2(left, rect.center().y - galley.size().y / 2.0),
galley,
theme::muted(),
);
left += width + theme::space::L;
}
let tick = 28.0;
let mut job = egui::text::LayoutJob::simple_singleline(
text.to_owned(),
theme::Role::Body.font(),
theme::text(),
);
job.wrap.max_width = rect.right() - tick - left;
job.wrap.max_rows = 1;
job.wrap.overflow_character = Some('…');
let galley = painter.layout_job(job);
painter.galley(
egui::pos2(left, rect.center().y - galley.size().y / 2.0),
galley,
theme::text(),
);
if selected {
painter.text(
egui::pos2(rect.right() - theme::space::L, rect.center().y),
Align2::RIGHT_CENTER,
egui_phosphor::regular::CHECK,
theme::Role::Body.font(),
theme::text(),
);
}
response.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, selected, text)
});
response
}
/// A heading inside a panel, over the choices it names.
fn panel_section(ui: &mut egui::Ui, text: &str) {
ui.add_space(theme::space::S);
ui.label(
theme::Role::Caption
.rich(text)
.family(theme::semibold())
.color(theme::muted()),
);
ui.add_space(theme::space::XS);
}
/// The audio and subtitle tracks, and what is worth changing about subtitles
/// mid-episode.
fn track_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
use pstr_player::TrackKind;
egui::ScrollArea::vertical()
.id_salt("panel-tracks")
.auto_shrink([false, false])
.show(ui, |ui| {
let audio: Vec<_> = playback.tracks_of(TrackKind::Audio).collect();
if !audio.is_empty() {
panel_section(ui, "Audio");
for track in &audio {
if option_row(ui, track.selected, &track.label(), None).clicked() {
actions.push(Action::SelectTrack {
kind: TrackKind::Audio,
id: Some(track.id),
});
}
}
}
let subtitles: Vec<_> = playback.tracks_of(TrackKind::Subtitle).collect();
panel_section(ui, "Subtitles");
// "Off" first, and always: it is the choice most often wanted, and
// the only one that is not in the file.
let off = playback.selected_track(TrackKind::Subtitle).is_none();
if option_row(ui, off, "Off", None).clicked() {
actions.push(Action::SelectTrack {
kind: TrackKind::Subtitle,
id: None,
});
}
for track in &subtitles {
if option_row(ui, track.selected, &track.label(), None).clicked() {
actions.push(Action::SelectTrack {
kind: TrackKind::Subtitle,
id: Some(track.id),
});
}
}
if !subtitles.is_empty() {
panel_section(ui, "Subtitle size, place and timing");
ui::transport::subtitle_adjustments(ui, playback, actions);
}
});
}
/// The speeds `[` and `]` step through.
fn speed_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
for speed in ui::transport::SPEEDS {
let label = if speed == 1.0 {
"Normal".to_owned()
} else {
ui::format_speed(speed)
};
if option_row(ui, (playback.speed - speed).abs() < 1e-6, &label, None).clicked() {
actions.push(Action::SetSpeed(speed));
}
}
}
/// Every chapter, with where it starts, the one playing marked.
fn chapter_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let current = playback.chapter().map(|chapter| chapter.index);
egui::ScrollArea::vertical()
.id_salt("panel-chapters")
.auto_shrink([false, false])
.show(ui, |ui| {
for chapter in &playback.chapters {
let start = ui::format_time(chapter.start);
if option_row(
ui,
current == Some(chapter.index),
&chapter.label(),
Some(&start),
)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
}
}
});
}
/// The title's episodes, a season at a time, opened on the playing one.
fn episode_list(
ui: &mut egui::Ui,
list: &mut Episodes<'_>,
playback: &Playback,
actions: &mut Vec<Action>,
) {
let ctx = ui.ctx().clone();
let title = list.title;
let target = &playback.target;
let is_playing = |episode: &pstr_core::library::Episode| {
@@ -847,97 +1159,148 @@ fn episode_drawer(
.unwrap_or(playing_season)
.min(title.seasons.len().saturating_sub(1));
let margin = theme::space::L;
egui::Area::new(egui::Id::new("episode-drawer"))
.order(egui::Order::Foreground)
.fixed_pos(area.min)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::surface().gamma_multiply(0.96))
.corner_radius(CornerRadius::same(theme::radius::LG))
.inner_margin(egui::Margin::same(margin as i8))
.shadow(theme::tile_shadow(1.0))
.show(ui, |ui| {
ui.set_width(area.width() - 2.0 * margin);
ui.set_height(area.height() - 2.0 * margin);
ui.horizontal(|ui| {
ui.label(theme::Role::Heading.rich("Episodes").color(theme::text()));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(
theme::Role::Subhead
.rich(egui_phosphor::regular::X)
.color(theme::muted()),
)
.frame(false),
)
.hint("Close (E)")
.clicked()
{
actions.push(Action::ToggleEpisodes);
}
});
});
if title.seasons.len() > 1 {
ui.add_space(theme::space::XS);
let labels: Vec<(usize, String)> = title
.seasons
.iter()
.enumerate()
.map(|(index, season)| (index, season.label()))
.collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.map(|(index, label)| (*index, label.as_str()))
.collect();
egui::ScrollArea::horizontal()
.id_salt("drawer-seasons")
.show(ui, |ui| {
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
season = picked;
ctx.data_mut(|data| data.insert_temp(season_id, picked));
}
});
}
ui.add_space(theme::space::S);
if title.seasons.len() > 1 {
let labels: Vec<(usize, String)> = title
.seasons
.iter()
.enumerate()
.map(|(index, season)| (index, season.label()))
.collect();
let choices: Vec<(usize, &str)> = labels
.iter()
.map(|(index, label)| (*index, label.as_str()))
.collect();
egui::ScrollArea::horizontal()
.id_salt("drawer-seasons")
.show(ui, |ui| {
if let Some(picked) = ui::widgets::segmented(ui, season, &choices) {
season = picked;
ctx.data_mut(|data| data.insert_temp(season_id, picked));
}
});
ui.add_space(theme::space::S);
}
let Some(shown) = title.seasons.get(season) else {
return;
};
egui::ScrollArea::vertical()
.id_salt("drawer-episodes")
.auto_shrink([false, false])
.show(ui, |ui| {
for episode in &shown.episodes {
let row = drawer_row(
ui,
&mut list.art,
title,
episode,
is_playing(episode),
actions,
);
if is_playing(episode) {
let scrolled = ctx.data(|data| {
data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED))
});
if scrolled.as_deref() != Some(target.link_id.as_str()) {
row.scroll_to_me(Some(egui::Align::Center));
ctx.data_mut(|data| {
data.insert_temp(
egui::Id::new(DRAWER_SCROLLED),
target.link_id.clone(),
)
});
}
}
}
let Some(shown) = title.seasons.get(season) else {
return;
};
egui::ScrollArea::vertical()
.id_salt("drawer-episodes")
.auto_shrink([false, false])
.show(ui, |ui| {
for episode in &shown.episodes {
let row = drawer_row(
ui,
&mut list.art,
title,
episode,
is_playing(episode),
actions,
);
if is_playing(episode) {
let scrolled =
ctx.data(|data| data.get_temp::<String>(egui::Id::new(DRAWER_SCROLLED)));
if scrolled.as_deref() != Some(target.link_id.as_str()) {
row.scroll_to_me(Some(egui::Align::Center));
ctx.data_mut(|data| {
data.insert_temp(egui::Id::new(DRAWER_SCROLLED), target.link_id.clone())
});
});
}
}
}
});
}
/// What takes over from the controls once the picture has sat paused: the
/// picture dimmed, and over it what is playing, the way a cinema's screen says
/// what the film is between showings.
fn pause_screen(ui: &egui::Ui, playback: &Playback, about: Option<&About>, rect: Rect, fade: f32) {
let painter = ui.painter();
painter.rect_filled(
rect,
CornerRadius::ZERO,
Color32::from_black_alpha((150.0 * fade) as u8),
);
// Darker down the left, where the words are, so they hold over any frame.
let side = Rect::from_min_size(rect.min, Vec2::new(rect.width() * 0.6, rect.height()));
let mut mesh = egui::Mesh::default();
let dark = Color32::from_black_alpha((120.0 * fade) as u8);
mesh.colored_vertex(side.left_top(), dark);
mesh.colored_vertex(side.right_top(), Color32::TRANSPARENT);
mesh.colored_vertex(side.left_bottom(), dark);
mesh.colored_vertex(side.right_bottom(), Color32::TRANSPARENT);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(1, 3, 2);
painter.add(egui::Shape::mesh(mesh));
let ink = |alpha: u8| Color32::from_white_alpha(alpha).gamma_multiply(fade);
let left = rect.left() + (rect.width() * 0.07).max(32.0);
let width = (rect.width() * 0.42).clamp(260.0, 620.0);
// Built from the bottom of the block up, so its lowest line sits at the
// same height whatever the synopsis runs to.
let mut y = rect.bottom() - rect.height() * 0.22;
if let Some(synopsis) = about.and_then(|about| about.synopsis.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
synopsis.to_owned(),
theme::Role::Body.font(),
ink(215),
width,
);
job.wrap.max_rows = 4;
job.wrap.overflow_character = Some('…');
let galley = painter.layout_job(job);
y -= galley.size().y;
painter.galley(egui::pos2(left, y), galley, ink(215));
y -= theme::space::L;
}
let caption = playback.target.caption();
if !caption.is_empty() {
let galley = painter.layout(caption, theme::Role::Subhead.font(), ink(235), width);
y -= galley.size().y;
painter.galley(egui::pos2(left, y), galley, ink(235));
y -= theme::space::M;
}
match about.and_then(|about| about.logo.as_ref()) {
Some(logo) => {
let size = logo.size_vec2();
let scale = (width * 0.8 / size.x).min(110.0 / size.y);
let shown = size * scale;
y -= shown.y;
painter.image(
logo.id(),
Rect::from_min_size(egui::pos2(left, y), shown),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE.gamma_multiply(fade),
);
}
None => {
let name = about.map_or(&playback.target.title_name, |about| &about.name);
let galley = painter.layout(name.clone(), theme::Role::Hero.font(), ink(255), width);
y -= galley.size().y;
painter.galley(egui::pos2(left, y), galley, ink(255));
}
}
y -= theme::space::M;
painter.text(
egui::pos2(left, y),
Align2::LEFT_BOTTOM,
"You're watching",
theme::Role::Label.font(),
ink(190),
);
painter.text(
egui::pos2(
rect.right() - left + rect.left(),
rect.bottom() - rect.height() * 0.22,
),
Align2::RIGHT_BOTTOM,
"Paused",
theme::Role::Title.font(),
ink(200),
);
}
/// One episode in the list: its still, which plays it, and what it is.
fn drawer_row(
ui: &mut egui::Ui,
@@ -1080,40 +1443,131 @@ fn fade(painter: &egui::Painter, rect: Rect, curve: impl Fn(f32) -> f32) {
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;
/// How long a [`Flash`] stays, and how much of that is fading out.
pub const FLASH_SECONDS: f64 = 0.9;
const FLASH_FADE: f64 = 0.25;
let age = ctx.input(|input| input.time) - at;
if age >= SECONDS {
return false;
/// What a key or a click just did, shown in the middle of the picture for a
/// moment: "+30 s", "Volume 70%" with the level under it.
#[derive(Debug, Clone)]
pub struct Flash {
pub icon: &'static str,
pub text: String,
/// A level to draw as a bar under the text, from 0 to 1: the volume.
pub level: Option<f32>,
/// What a run of seeks in one direction adds up to, so that three
/// presses of → read "+90 s" rather than "+30 s" three times.
pub seek: Option<f64>,
/// When, on egui's clock.
pub at: f64,
}
impl Flash {
/// A seek of `seconds`, added to the one still showing when it went the
/// same way.
pub fn seek(previous: Option<&Flash>, seconds: f64, now: f64) -> Self {
let total = previous
.filter(|flash| now - flash.at < FLASH_SECONDS)
.and_then(|flash| flash.seek)
.filter(|total| total.signum() == seconds.signum())
.unwrap_or(0.0)
+ seconds;
Self {
icon: if total < 0.0 {
egui_phosphor::regular::CLOCK_COUNTER_CLOCKWISE
} else {
egui_phosphor::regular::CLOCK_CLOCKWISE
},
text: if total < 0.0 {
format!("− {:.0} s", -total)
} else {
format!("+ {total:.0} s")
},
level: None,
seek: Some(total),
at: now,
}
}
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
/// Draw it over `rect`. Returns whether it is still showing.
pub fn show(&self, ctx: &egui::Context, rect: Rect) -> bool {
let age = ctx.input(|input| input.time) - self.at;
if age >= FLASH_SECONDS {
return false;
}
let opacity = ((FLASH_SECONDS - age) / FLASH_FADE).clamp(0.0, 1.0) as f32;
let painter = ctx.layer_painter(egui::LayerId::new(
egui::Order::Foreground,
egui::Id::new("osd"),
));
let ink = Color32::WHITE.gamma_multiply(opacity);
let icon = painter.layout_no_wrap(self.icon.to_owned(), theme::Role::Hero.font(), ink);
let text = painter.layout_no_wrap(self.text.clone(), theme::Role::Subhead.font(), ink);
let bar = Vec2::new(120.0, 4.0);
let width = icon.size().x.max(text.size().x).max(bar.x) + 40.0;
let height = icon.size().y
+ theme::space::S
+ text.size().y
+ if self.level.is_some() {
theme::space::L + bar.y
} else {
0.0
}
+ 32.0;
let frame = Rect::from_center_size(rect.center(), Vec2::new(width, height));
painter.rect_filled(
frame,
CornerRadius::same(theme::radius::LG * 2),
Color32::from_black_alpha((170.0 * opacity) as u8),
);
let mut y = frame.top() + 16.0;
let icon_height = icon.size().y;
painter.galley(
egui::pos2(frame.center().x - icon.size().x / 2.0, y),
icon,
ink,
);
y += icon_height + theme::space::S;
let text_height = text.size().y;
painter.galley(
egui::pos2(frame.center().x - text.size().x / 2.0, y),
text,
ink,
);
y += text_height + theme::space::L;
if let Some(level) = self.level {
let track = Rect::from_min_size(egui::pos2(frame.center().x - bar.x / 2.0, y), bar);
let radius = CornerRadius::same(theme::radius::BAR);
painter.rect_filled(
track,
radius,
Color32::from_white_alpha(60).gamma_multiply(opacity),
);
let mut filled = track;
filled.set_width(track.width() * level.clamp(0.0, 1.0));
painter.rect_filled(filled, radius, theme::accent().gamma_multiply(opacity));
}
ctx.request_repaint();
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seeks_the_same_way_in_quick_succession_add_up_and_a_turn_or_a_pause_starts_again() {
let first = Flash::seek(None, 30.0, 10.0);
let second = Flash::seek(Some(&first), 30.0, 10.4);
assert_eq!(second.seek, Some(60.0));
assert_eq!(second.text, "+ 60 s");
let back = Flash::seek(Some(&second), -10.0, 10.6);
assert_eq!(back.seek, Some(-10.0));
assert_eq!(back.text, "− 10 s");
let later = Flash::seek(Some(&second), 30.0, 10.4 + FLASH_SECONDS);
assert_eq!(later.seek, Some(30.0));
}
}
+4 -1
View File
@@ -39,7 +39,10 @@ const GROUPS: &[(&str, &[(&str, &str)])] = &[
("Z / X", "Subtitles 0.1 s earlier and later"),
("E", "The list of episodes"),
("F or double-click", "Fullscreen"),
("Esc", "Leave fullscreen, then the player"),
(
"Esc",
"Close a panel, leave fullscreen, then leave the player",
),
],
),
];
+66 -52
View File
@@ -18,6 +18,7 @@ use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
use crate::ui::player::PlayerPanel;
use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
@@ -42,6 +43,9 @@ const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0;
/// The speeds on offer, and the steps `[` and `]` walk.
pub const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
@@ -54,6 +58,7 @@ pub fn full(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
panel: Option<PlayerPanel>,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
@@ -99,13 +104,11 @@ pub fn full(
}));
}
// Right cluster: everything that is a choice rather than a move.
// Right cluster: everything that is a choice rather than a move. Each
// opens its panel down the right of the picture rather than a menu
// under the pointer: a list of twelve chapters is read, not aimed at.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(theme::space::M);
speed_menu(ui, playback, actions);
ui.add_space(theme::space::M);
chapter_menu(ui, playback, actions);
panel_buttons(ui, playback, panel, actions);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
@@ -494,7 +497,7 @@ fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>
/// What is worth changing mid-episode without a trip to Settings: the size and
/// place of the line, and its timing for a release that is out of step.
fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
pub fn subtitle_adjustments(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let mut style = playback.subtitle_style.clone();
egui::Grid::new("subtitle-adjustments")
.num_columns(2)
@@ -547,54 +550,65 @@ 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<Action>) {
let label = if (playback.speed - 1.0).abs() < 1e-6 {
"Speed".to_owned()
} else {
format!("Speed · {}", ui::format_speed(playback.speed))
/// The buttons that open the player's panels, right to left: tracks, speed,
/// chapters. Each shows only when there is a choice to make, and says what is
/// chosen when that is worth noticing at a glance.
fn panel_buttons(
ui: &mut egui::Ui,
playback: &Playback,
open: Option<PlayerPanel>,
actions: &mut Vec<Action>,
) {
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
let response = if open == Some(panel) {
ui::accent_button(ui, &text)
} else {
ui::glass_button(ui, &text)
};
if response.on_hover_text(hint).clicked() {
actions.push(Action::TogglePanel(panel));
}
ui.add_space(theme::space::S);
};
ui.menu_button(label, |ui| {
// The same steps `[` and `]` walk.
for speed in [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.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<Action>) {
if playback.chapters.len() < 2 {
return;
let subtitles = playback.tracks_of(TrackKind::Subtitle).count();
let audio = playback.tracks_of(TrackKind::Audio).count();
if subtitles > 0 || audio > 1 {
let shown = match playback.selected_track(TrackKind::Subtitle) {
Some(track) => short(track),
None if subtitles > 0 => "Off".to_owned(),
None => playback
.selected_track(TrackKind::Audio)
.map(short)
.unwrap_or_default(),
};
button(
ui,
PlayerPanel::Tracks,
format!("{} {shown}", egui_phosphor::regular::SUBTITLES),
"Audio and subtitles",
);
}
let speed = if (playback.speed - 1.0).abs() < 1e-6 {
egui_phosphor::regular::GAUGE.to_owned()
} else {
format!(
"{} {}",
egui_phosphor::regular::GAUGE,
ui::format_speed(playback.speed)
)
};
button(ui, PlayerPanel::Speed, speed, "Playback speed ([ and ])");
// Only for a file with more than one, which for most releases means anime
// and not much else.
if playback.chapters.len() > 1 {
button(
ui,
PlayerPanel::Chapters,
egui_phosphor::regular::LIST_NUMBERS.to_owned(),
"Chapters",
);
}
let current = playback.chapter().map(|chapter| chapter.index);
ui.menu_button("Chapters", |ui| {
for chapter in &playback.chapters {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
if ui
.selectable_label(current == Some(chapter.index), label)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
ui.close();
}
}
})
.response
.on_hover_text("Chapters");
}
/// A track in as few words as fit on a button: what distinguishes it from the
+11 -5
View File
@@ -139,11 +139,17 @@ The picture plays inside the app's window. Move the mouse to show the controls.
credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**.
- **Subtitles and audio** are chosen from the buttons at the bottom right. The app remembers your
choice for each show. The subtitle menu also has **Size** and **Position**, which are saved like
the ones in Settings and change styled subtitles only when they are restyled, and **Delay**,
which moves subtitles earlier or later for this file only.
- **Subtitles and audio**, **Speed** and **Chapters** are buttons at the bottom right. Each opens
a panel down the right of the picture; click the button again, or press Esc, to close it.
- **Subtitles and audio** remembers your choice for each show. The panel also has **Size** and
**Position**, which are saved like the ones in Settings and change styled subtitles only when
they are restyled, and **Delay**, which moves subtitles earlier or later for this file only.
- **Speed** goes from 0.5× to 3×, and stays for the next episode.
- **Seeking and volume** show what they did in the middle of the picture. Several presses of ← or
→ add up, so three presses of → say "+ 90 s".
- **Paused** for 5 seconds with the mouse still, the picture dims and shows what you are watching:
the show's logo or name, the episode, and what it is about. Move the mouse to get the controls
back.
### Keyboard
@@ -173,7 +179,7 @@ Press **?** anywhere to see this list in the app.
| E | The list of episodes |
| F or double-click | Fullscreen |
| Ctrl+S | Save the frame to `Pictures/proton-stream` |
| Esc | Leave fullscreen, then the player |
| Esc | Close a panel, leave fullscreen, then leave the player |
Media keys and your desktop's media controls work too.