feat(app,android,ios): swipe gestures, touch lock and sleep timer

On Android and iOS a swipe up or down on the left half of the picture
sets the brightness and on the right half the volume; a sideways swipe
seeks, showing where it will land and seeking only when the finger
lifts. Swipes that start at the top or bottom edge are left to the
system. Android turns the device's media volume; iOS lets no app do
that, so there the swipe turns the player's own, as its slider does.
Brightness set by a swipe is handed back when the player goes.

Lock touch, in the player's menu, makes the picture ignore touches and
the back gesture; a tap brings up Unlock for a few seconds.

The sleep timer pauses after 30 or 60 minutes, or stops at the end of
the episode playing instead of going on to the next, with no up-next
card. On the desktop it is a panel like the others, behind a moon
button that shows what is left; on Android and iOS it lives with the
player host, so it keeps running with the screen off.
This commit is contained in:
nvrl committed 2026-10-31 03:19:04 +01:00
1 parent cf8ee58f6b
commit 973a8f2749
11 files changed
+827 -25

No files matched your search

+89 -1
View File
@@ -283,6 +283,8 @@ pub enum Action {
},
/// Open or close one of the panels beside the picture.
TogglePanel(ui::player::PlayerPanel),
/// Pause when this runs out, or turn the timer off.
SetSleep(Option<ui::player::SleepChoice>),
/// Put watch states back as they were — what Undo does.
RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>),
ClearStreamCache,
@@ -735,6 +737,8 @@ pub struct App {
subtitle_style_unsaved: bool,
/// Which panel is out beside the picture on the player page.
pub player_panel: Option<ui::player::PlayerPanel>,
/// The sleep timer, when one is set.
sleep: Option<Sleep>,
/// 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,
@@ -845,6 +849,7 @@ impl App {
subtitle_style_unsaved: false,
fullscreen: false,
player_panel: None,
sleep: None,
opening: None,
connecting: true,
crawling: false,
@@ -1376,7 +1381,7 @@ impl App {
actions: &mut Vec<Action>,
) -> Option<UpNextCard> {
let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next;
let autoplay = self.engine.playback_prefs().autoplay_next && !self.stops_after_this();
let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else {
@@ -1462,6 +1467,34 @@ impl App {
})
}
/// Whether the sleep timer is set to stop at the end of what is playing.
fn stops_after_this(&self) -> bool {
matches!(
(self.sleep, &self.playback),
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id
)
}
/// Pause once the sleep timer runs out. A timer for the end of an episode
/// that is no longer playing — the viewer moved on to another — is
/// dropped: it was about that episode.
fn tick_sleep(&mut self, ctx: &egui::Context) {
match (self.sleep, &self.playback) {
(Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => {
if !playback.paused {
playback.send(crate::playback::Command::TogglePause);
}
self.sleep = None;
self.note(ctx, "Sleep timer: paused", false);
}
(Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => {
self.sleep = None;
}
(Some(_), None) => self.sleep = None,
_ => {}
}
}
/// Once a file has loaded with no chapters naming its opening or ending,
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
fn ask_skip_times(&mut self) {
@@ -1555,6 +1588,11 @@ impl App {
/// end too, and auto-advancing through a broken share one episode at a
/// time is how a player earns being turned off.
fn autoplay_next(&mut self, ctx: &egui::Context) {
if self.stops_after_this() {
// The timer has done its job.
self.sleep = None;
return;
}
if !self.engine.playback_prefs().autoplay_next {
return;
}
@@ -2078,6 +2116,29 @@ impl App {
Action::RemoveFromHistory { share_id, link_id } => {
self.forget_watch(ctx, share_id, link_id, "history");
}
Action::SetSleep(choice) => {
self.sleep = match choice {
None => None,
Some(ui::player::SleepChoice::EndOfEpisode) => self
.playback
.as_ref()
.map(|playback| Sleep::EndOfEpisode(playback.id)),
Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At(
std::time::Instant::now()
+ std::time::Duration::from_secs(u64::from(minutes) * 60),
)),
};
let text = match choice {
None => "Sleep timer off".to_owned(),
Some(ui::player::SleepChoice::EndOfEpisode) => {
"Stopping after this episode".to_owned()
}
Some(ui::player::SleepChoice::Minutes(minutes)) => {
format!("Pausing in {minutes} min")
}
};
self.flash(ctx, egui_phosphor::regular::MOON, text);
}
Action::TogglePanel(panel) => {
self.player_panel = (self.player_panel != Some(panel)).then_some(panel);
}
@@ -2345,6 +2406,7 @@ impl eframe::App for App {
}
self.warm_next();
self.ask_skip_times();
self.tick_sleep(ctx);
self.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx);
@@ -2404,6 +2466,7 @@ impl eframe::App for App {
metadata,
episodes,
player_panel,
sleep,
..
} = self;
// Before the list borrows the art caches for the rest of the frame.
@@ -2498,6 +2561,7 @@ impl eframe::App for App {
episodes: list,
panel: *player_panel,
about,
sleep: sleep.map(Sleep::choice),
},
&mut actions,
);
@@ -3165,6 +3229,30 @@ struct PlayerKeys {
skip_to: Option<f64>,
}
/// A sleep timer that is running.
#[derive(Debug, Clone, Copy)]
enum Sleep {
/// Stop when player `id`'s file ends, rather than going on to the next.
EndOfEpisode(u64),
/// Pause at this moment.
At(std::time::Instant),
}
impl Sleep {
/// What the panel shows as chosen.
fn choice(self) -> ui::player::SleepChoice {
match self {
Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode,
Self::At(at) => ui::player::SleepChoice::Minutes(
(at.saturating_duration_since(std::time::Instant::now())
.as_secs()
/ 60) as u32
+ 1,
),
}
}
}
/// 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.
use crate::ui::transport::SPEEDS;
+46 -1
View File
@@ -89,8 +89,22 @@ pub enum PlayerPanel {
Tracks,
Speed,
Chapters,
Sleep,
}
/// A sleep timer, as chosen or as it stands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SleepChoice {
/// Stop at the end of the episode playing, rather than go on to the next.
EndOfEpisode,
/// Pause after this many minutes. As it stands, the minutes left, rounded
/// up.
Minutes(u32),
}
/// The timers on offer, besides the end of the episode.
const SLEEP_MINUTES: [u32; 2] = [30, 60];
impl PlayerPanel {
fn heading(self) -> &'static str {
match self {
@@ -98,6 +112,7 @@ impl PlayerPanel {
Self::Tracks => "Audio and subtitles",
Self::Speed => "Playback speed",
Self::Chapters => "Chapters",
Self::Sleep => "Sleep timer",
}
}
}
@@ -107,6 +122,7 @@ impl PlayerPanel {
struct Panels {
episodes: bool,
open: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
}
/// What the pause screen says about what is playing.
@@ -172,6 +188,8 @@ pub struct Chrome<'a> {
pub panel: Option<PlayerPanel>,
/// What the pause screen shows, while the picture is paused.
pub about: Option<About>,
/// The sleep timer, when one is running.
pub sleep: Option<SleepChoice>,
}
/// The playing title's episodes, for the list beside the picture.
@@ -293,6 +311,7 @@ pub fn show(
mut episodes,
panel,
about,
sleep,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
@@ -383,6 +402,7 @@ pub fn show(
Panels {
episodes: episodes.is_some(),
open: panel,
sleep,
},
rect,
overlay.chrome_height,
@@ -403,6 +423,7 @@ pub fn show(
(PlayerPanel::Tracks, _) => track_list(ui, playback, actions),
(PlayerPanel::Speed, _) => speed_list(ui, playback, actions),
(PlayerPanel::Chapters, _) => chapter_list(ui, playback, actions),
(PlayerPanel::Sleep, _) => sleep_list(ui, sleep, actions),
(PlayerPanel::Episodes, None) => {}
},
);
@@ -902,7 +923,7 @@ fn chrome(
.max_rect(content)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui::transport::full(ui, playback, neighbours, panels.open, actions);
ui::transport::full(ui, playback, neighbours, panels.open, panels.sleep, actions);
ui.min_rect().height()
},
)
@@ -1112,6 +1133,30 @@ fn speed_list(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>)
}
}
/// The sleep timers, and what the running one has left.
fn sleep_list(ui: &mut egui::Ui, sleep: Option<SleepChoice>, actions: &mut Vec<Action>) {
if let Some(SleepChoice::Minutes(left)) = sleep {
ui.label(
theme::Role::Label
.rich(format!("Pausing in {left} min"))
.color(theme::muted()),
);
ui.add_space(theme::space::S);
}
if option_row(ui, sleep.is_none(), "Off", None).clicked() {
actions.push(Action::SetSleep(None));
}
let end = sleep == Some(SleepChoice::EndOfEpisode);
if option_row(ui, end, "End of this episode", None).clicked() {
actions.push(Action::SetSleep(Some(SleepChoice::EndOfEpisode)));
}
for minutes in SLEEP_MINUTES {
if option_row(ui, false, &format!("{minutes} minutes"), None).clicked() {
actions.push(Action::SetSleep(Some(SleepChoice::Minutes(minutes))));
}
}
}
/// 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);
+13 -4
View File
@@ -18,7 +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::player::{PlayerPanel, SleepChoice};
use crate::ui::widgets::Named;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
@@ -59,6 +59,7 @@ pub fn full(
playback: &Playback,
neighbours: Neighbours,
panel: Option<PlayerPanel>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
@@ -108,7 +109,7 @@ pub fn full(
// 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| {
panel_buttons(ui, playback, panel, actions);
panel_buttons(ui, playback, panel, sleep, actions);
ui.add_space(theme::space::M);
volume(ui, playback, actions);
});
@@ -551,12 +552,13 @@ fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions:
}
/// 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.
/// chapters, sleep. 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>,
sleep: Option<SleepChoice>,
actions: &mut Vec<Action>,
) {
let mut button = |ui: &mut egui::Ui, panel: PlayerPanel, text: String, hint: &str| {
@@ -609,6 +611,13 @@ fn panel_buttons(
"Chapters",
);
}
let moon = egui_phosphor::regular::MOON;
let sleep = match sleep {
None => moon.to_owned(),
Some(SleepChoice::EndOfEpisode) => format!("{moon} End"),
Some(SleepChoice::Minutes(left)) => format!("{moon} {left} min"),
};
button(ui, PlayerPanel::Sleep, sleep, "Sleep timer");
}
/// A track in as few words as fit on a button: what distinguishes it from the