Files
proton-stream/crates/pstr-app/src/app.rs
T
nvrl 959547cdc8 feat(app): media keys and desktop media widget, keep the screen on during a film
- MPRIS on Linux and SMTC on Windows through souvlaki: play/pause, next,
  previous, stop and seeking from media keys and the panel widget, with
  the title, episode, duration and the cached cover as a file:// URL
- the screensaver is held off while a film plays on the player page,
  through the desktop portal with org.freedesktop.ScreenSaver as the
  fallback, and SetThreadExecutionState on Windows. mpv's own
  stop-screensaver has no window to act for under the render API
2026-10-02 20:16:27 +02:00

2060 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The window: state, the event pump, and what a click turns into.
//!
//! One rule holds this together — **drawing never mutates**. A page is handed
//! what it needs by reference and pushes [`Action`]s onto a list; the list is
//! applied after the frame. That is what lets a card click change the page it
//! is drawn on, and it is why none of the `ui` modules take an `&mut App`.
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use pstr_core::Share;
use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
use crate::engine::{
DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state,
};
use crate::pacing::Pacer;
use crate::playback::{Playback, PlaybackTarget};
use crate::ui::player::UpNextCard;
use crate::{theme, ui};
/// A frame slower than this is not a slow frame, it is a hang.
///
/// The UI thread has only a handful of blocking calls in it — building an mpv
/// instance, tearing one down, the OpenGL work in between — and every one of
/// them is somewhere the compositor will decide the window has stopped
/// answering. There is no way to tell afterwards which one it was, so the ones
/// that can block say how long they took, and the frame as a whole says so too.
const STALL: std::time::Duration = std::time::Duration::from_millis(300);
/// Time a frame, and log what it was doing if it ran long.
///
/// A guard rather than a wrapper because [`eframe::App::ui`] returns from two
/// places, and the interesting one is the early return for the player page.
struct FrameTimer {
started: std::time::Instant,
page: &'static str,
}
impl FrameTimer {
fn new(page: &Page) -> Self {
Self {
started: std::time::Instant::now(),
page: match page {
Page::Library => "library",
Page::Title(_) => "title",
Page::Shares => "shares",
Page::Downloads => "downloads",
Page::Settings => "settings",
Page::Player => "player",
},
}
}
}
impl Drop for FrameTimer {
fn drop(&mut self) {
let took = self.started.elapsed();
if took >= STALL {
tracing::warn!(
"ui thread blocked for {} ms drawing the {} page",
took.as_millis(),
self.page
);
}
}
}
/// How long the "up next" card counts down before the next episode starts.
///
/// Ten seconds is what every service settled on, and the reason is the same
/// here: long enough to read what is coming and to say no, short enough that
/// sitting through it is not a decision.
const UP_NEXT_SECONDS: f64 = 10.0;
/// Which page is showing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Page {
Library,
/// One title, by [`pstr_core::library::Title::key`].
Title(String),
Shares,
Downloads,
/// How the app looks, how it plays, and what it looks up.
Settings,
/// The picture, filling the window. Leaving this page does not stop
/// playback — the transport bar at the bottom is how you get back to it.
Player,
}
/// Something a click asked for, applied once the frame is drawn.
pub enum Action {
Goto(Page),
Play(PlaybackTarget),
/// Download one or more files as complete local copies.
MakeOffline(Vec<PlaybackTarget>),
PauseDownload(DownloadKey),
ResumeDownload(DownloadKey),
CancelDownload(DownloadKey),
RemoveDownload(DownloadKey, bool),
/// Crawl one share, or every share.
Crawl(Option<String>),
/// Stop the crawls still listing.
StopCrawl,
AddShare {
name: String,
url: String,
password: Option<String>,
},
/// Ask whether to forget a share. [`Action::ForgetShare`] does it.
RemoveShare(String),
ForgetShare(String),
/// Flip an episode between seen and unseen by hand.
SetWatched {
share_id: String,
link_id: String,
watched: bool,
duration: Option<f64>,
},
Player(crate::playback::Command),
/// Change the volume, 0–100. `commit` writes it to the preferences; a
/// slider mid-drag does not.
SetVolume {
volume: f64,
commit: bool,
},
ToggleMute,
/// Play this track of that kind, or none of it, and remember the language
/// for the next file.
SelectTrack {
kind: pstr_player::TrackKind,
id: Option<i64>,
},
/// Turn enrichment on or off, or change provider.
SetMetadataConfig(MetadataConfig),
SetApiKey {
provider: pstr_core::metadata::ProviderId,
key: String,
},
/// Look every title up. `force` re-asks about ones already matched.
MatchTitles {
force: bool,
},
/// Open the hand-matching search for one title, seeded with its own name.
OpenMatcher(String),
CloseMatcher,
/// Ask the provider what the text in the box might be.
SearchMatches,
/// Pin the open title to this entry.
ChooseMatch(Box<pstr_core::metadata::TitleMetadata>),
/// Forget what is stored for a title, so it is matched from scratch again.
ForgetMatch(String),
/// Play the file before or after the one playing, within its title.
PlayAdjacent(Adjacent),
/// Start or stop the next episode playing on its own at the end of one.
SetAutoplay(bool),
/// Play faster or slower, and keep doing so for the next file.
SetSpeed(f64),
/// Seek past openings and credits without being asked.
SetAutoSkip(bool),
/// Repaint the window in a different palette.
SetAppearance(Appearance),
/// In or out of fullscreen, from the player page.
ToggleFullscreen,
/// Stop watching, keep playing: back to the page this film came from, with
/// the transport bar at the bottom still driving it.
LeavePlayer,
/// Call off the end-of-episode countdown and let this file play out.
WatchToEnd,
/// Put the caret in the search box, from anywhere in the library.
FocusSearch,
/// Show a different slice of the library, or in a different order.
SetShelf(Filter, Sort),
/// Open or close the list of keyboard shortcuts.
ToggleShortcuts,
/// One page back: a title to the library, the library to nowhere.
Back,
}
/// The end-of-episode countdown.
///
/// Playback reaching the credits is not the same thing as the file ending, and
/// this is the difference: the run of chapters that closes an episode is known
/// (see [`pstr_player::credits_start`]), so the next episode can be offered
/// while the last one is still playing rather than after a black screen.
///
/// It is deliberately per-file — a new player resets it — and deliberately
/// dismissable *for the rest of the file*: a viewer who wants to hear the
/// ending song should be asked once, not once a second.
#[derive(Debug, Default)]
pub struct UpNext {
/// Which player this belongs to. A different one means a new file, and a
/// new file means the viewer's last answer no longer applies.
playback_id: u64,
/// When the countdown started, on egui's clock. `None` while there is
/// nothing to count down.
started: Option<f64>,
/// The viewer said to play this one out, or the countdown has already
/// fired. Either way, do not ask again about this file.
dismissed: bool,
}
/// Which way to step through a title's files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Adjacent {
Previous,
Next,
}
/// Picking a title's entry by hand.
///
/// The escape hatch from the matcher, which is deliberately unwilling to guess:
/// [`pstr_meta::matching::MATCH_FLOOR`] is set where a wrong poster is worse
/// than none, and the cost of that is a handful of titles that match nothing.
/// The `Fate/stay night [Heaven's Feel]` films are the standing example — three
/// films in one folder, filed by AniList as three separate entries, so there is
/// no answer for the scorer to find and only the viewer knows which one the
/// folder means.
pub struct Matcher {
/// The title being matched, by [`pstr_core::library::Title::key`].
pub title_key: String,
/// Its name as the library has it, for the dialog's own heading.
pub title_name: String,
pub kind: pstr_core::library::TitleKind,
/// What is in the search box. Seeded with the library's name for the title,
/// which is the thing that failed — so it is the right starting point to
/// edit rather than to re-send.
pub query: String,
pub results: Vec<pstr_core::metadata::TitleMetadata>,
/// A request is out. The box stays usable; only the button is held.
pub searching: bool,
/// Whether a search has come back yet, so an empty list can read as "nothing
/// found" rather than "nothing asked".
pub asked: bool,
pub error: Option<String>,
/// Set for the frame the dialog opens, to put the caret in the box.
pub focus: bool,
}
impl Matcher {
pub fn new(title: &pstr_core::library::Title) -> Self {
Self {
title_key: title.key.clone(),
title_name: title.name.clone(),
kind: title.kind,
query: title.name.clone(),
results: Vec::new(),
searching: false,
asked: false,
error: None,
focus: true,
}
}
}
/// What the library page lists, worked out once per change rather than once a
/// frame.
///
/// A search lowercases every filename in the library, and the shelf sorts
/// every title by when it was last played. Neither is slow once; both were
/// being done on every frame, and while a film plays under the transport bar
/// that is the film's frame rate.
#[derive(Default)]
pub struct LibraryView {
/// The query `matches` answers, as typed.
query: String,
/// Which titles the grid shows, and in what order.
pub filter: Filter,
pub sort: Sort,
/// The filter and order `matches` was built with.
built_with: (Filter, Sort),
/// Indices into [`Library::titles`].
pub matches: Vec<usize>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// The library changed under the lists.
stale: bool,
}
/// Which titles the grid shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Filter {
#[default]
All,
Series,
Films,
/// Started and not finished.
Watching,
/// Not started.
Unwatched,
}
impl Filter {
fn admits(self, title: &pstr_core::library::Title) -> bool {
use pstr_core::library::TitleKind;
let watched = title.watched_count();
match self {
Self::All => true,
Self::Series => title.kind == TitleKind::Series,
Self::Films => title.kind == TitleKind::Film,
Self::Watching => {
title.resume().is_some() || (watched > 0 && watched < title.episode_count())
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
}
}
}
/// What order the grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Sort {
/// The library's own order: by name, ignoring a leading article.
#[default]
Name,
/// Most recently played first.
Recent,
/// Newest first, undated last.
Year,
}
impl LibraryView {
fn refresh(&mut self, library: &Library, query: &str) {
if !self.stale && self.query == query && self.built_with == (self.filter, self.sort) {
return;
}
let index_of = |title: &pstr_core::library::Title| {
library
.titles
.iter()
.position(|candidate| std::ptr::eq(candidate, title))
};
let mut matches: Vec<&pstr_core::library::Title> = library
.search(query)
.into_iter()
.filter(|title| self.filter.admits(title))
.collect();
match self.sort {
Sort::Name => {}
// Stable, so titles never played keep their alphabetical order
// after the ones that have been.
Sort::Recent => matches.sort_by_key(|title| std::cmp::Reverse(title.last_played())),
Sort::Year => matches.sort_by_key(|title| std::cmp::Reverse(title.year.unwrap_or(0))),
}
self.matches = matches.into_iter().filter_map(index_of).collect();
self.built_with = (self.filter, self.sort);
if self.stale {
self.resumable = library
.continue_watching()
.into_iter()
.filter_map(index_of)
.collect();
}
self.query = query.to_owned();
self.stale = false;
}
}
/// The share the viewer is typing in.
#[derive(Default)]
pub struct ShareForm {
pub name: String,
pub url: String,
pub has_password: bool,
pub password: String,
/// Sent, and not answered yet. The form keeps what was typed until it is,
/// so a refused link can be corrected rather than pasted again.
pub sending: bool,
/// Why the last one was refused.
pub error: Option<String>,
}
pub struct App {
engine: Engine,
events: Receiver<Event>,
pub page: Page,
pub library: Library,
pub shares: Vec<Share>,
/// Proton's own per-file thumbnails.
pub thumbs: ImageCache,
/// Artwork from a metadata provider, keyed by title key.
pub posters: ImageCache,
/// What the providers have said, keyed by title key.
pub metadata: HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles.
pub episodes: HashMap<String, EpisodeGuide>,
pub settings: MetadataConfig,
/// What the viewer is typing into the API key box. Never persisted here —
/// it goes straight to the credential store on save.
pub api_key: String,
pub matching: bool,
/// The hand-matching dialog, while it is open.
pub matcher: Option<Matcher>,
pub search: String,
/// Set for the frame the search box should take the caret.
focus_search: bool,
/// 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)>,
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".
pub loaded: bool,
pub form: ShareForm,
pub playback: Option<Playback>,
/// Whether the player page's controls are showing, and why.
pub overlay: ui::player::Overlay,
/// The end-of-episode countdown for whatever is playing.
pub up_next: UpNext,
/// The player whose next episode has already been fetched ahead, so it is
/// asked for once per file rather than once a frame.
warmed: u64,
/// Chapters already skipped on their own in this player, so seeking back
/// into an opening on purpose is not undone a frame later.
auto_skipped: (u64, Vec<i64>),
/// 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,
/// The file being opened, if a click is still waiting on the network.
pub opening: Option<String>,
pub connecting: bool,
pub crawling: bool,
/// Which share the crawl is on, and how far it has got.
/// Files found so far, by share name, for each share still listing.
crawl_progress: std::collections::BTreeMap<String, usize>,
/// The window's size, written at exit for the next launch.
window: crate::window::WindowMemory,
/// Media keys and the desktop's media widget.
media: crate::media::MediaSession,
/// The cover handed to it, worked out once per player.
media_cover: Option<(u64, Option<String>)>,
/// Holds the screensaver off while a film is on screen and playing.
inhibitor: crate::inhibit::Inhibitor,
pub downloads: Vec<DownloadItem>,
pub offline_files: std::collections::HashSet<DownloadKey>,
pub confirm_partial_delete: Option<DownloadKey>,
/// A share the viewer asked to remove, waiting on their say-so.
pub confirm_remove_share: Option<String>,
/// Messages in the corner. See [`ui::toast`].
pub toasts: ui::toast::Toasts,
/// The frame ceiling, where this session is pacing itself rather than
/// letting vsync do it — see [`crate::pacing`].
pacer: Pacer,
}
impl App {
pub fn new(
cc: &eframe::CreationContext<'_>,
runtime: std::sync::Arc<tokio::runtime::Runtime>,
dirs: AppDirs,
) -> anyhow::Result<Self> {
// After the engine, not before: the engine is what reads the stored
// theme, and a window that paints one frame in the default palette
// before switching is a window that flashes on every launch.
let window = crate::window::WindowMemory::new(dirs.window_file());
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
theme::install_font_fallbacks(&cc.egui_ctx);
theme::apply(&cc.egui_ctx, engine.appearance());
// Paint from the catalog immediately; the network catches up. A library
// that was crawled yesterday is on screen before the shares open.
engine.load_shares();
engine.load_library();
engine.load_metadata();
engine.connect();
let settings = engine.metadata_config();
Ok(Self {
engine,
events,
page: Page::Library,
library: Library::default(),
shares: Vec::new(),
thumbs: ImageCache::default(),
posters: ImageCache::default(),
metadata: HashMap::new(),
episodes: HashMap::new(),
settings,
api_key: String::new(),
matching: false,
matcher: None,
search: String::new(),
focus_search: false,
shortcuts_open: false,
osd: None,
view: LibraryView::default(),
loaded: false,
form: ShareForm::default(),
playback: None,
overlay: ui::player::Overlay::default(),
up_next: UpNext::default(),
warmed: 0,
auto_skipped: (0, Vec::new()),
fullscreen: false,
opening: None,
connecting: true,
crawling: false,
crawl_progress: Default::default(),
window,
media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)),
media_cover: None,
inhibitor: crate::inhibit::Inhibitor::new(),
downloads: Vec::new(),
offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None,
confirm_remove_share: None,
toasts: ui::toast::Toasts::default(),
pacer: Pacer::new(),
})
}
/// 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>) {
if self.page == Page::Player {
self.osd = Some((text.into(), ctx.input(|input| input.time)));
}
}
fn note(&mut self, ctx: &egui::Context, text: impl Into<String>, error: bool) {
let now = ctx.input(|input| input.time);
self.toasts.push(now, text, error);
}
/// Drain everything the background side has said since the last frame.
///
/// Takes `frame` because starting a player is one of the things that can
/// come out of this channel, and building the mpv render context needs
/// eframe's OpenGL context — which is current here and nowhere else. See
/// [`crate::playback`].
fn pump(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
while let Ok(event) = self.events.try_recv() {
match event {
Event::Shares(shares) => self.shares = shares,
Event::ShareAdded(name) => {
self.form = ShareForm::default();
self.note(ctx, format!("added {name}"), false);
}
Event::ShareRejected(error) => {
// Nothing was crawled, so nothing will say it finished.
self.crawling = false;
self.form.sending = false;
self.form.error = Some(error);
}
Event::Connected { failures } => {
self.connecting = false;
if let Some(text) = describe_failures(&failures) {
self.note(ctx, text, true);
}
}
Event::ConnectFailed(error) => {
self.connecting = false;
self.note(ctx, format!("could not open the shares: {error}"), true);
}
Event::LibraryLoaded(library) => {
self.library = library;
self.loaded = true;
self.view.stale = true;
}
Event::Crawled {
share_id,
nodes,
files,
seconds,
} => {
let name = self.share_name(&share_id);
self.crawl_progress.remove(&name);
self.note(
ctx,
format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"),
false,
);
}
Event::CrawlFinished => {
self.crawling = false;
self.crawl_progress.clear();
}
Event::CrawlProgress { share, found } => {
self.crawling = true;
self.crawl_progress.insert(share, found);
}
Event::CrawlStopped { share_id } => {
let name = self.share_name(&share_id);
self.crawl_progress.remove(&name);
self.note(ctx, format!("stopped crawling {name}"), false);
}
Event::Thumbnail { key, image } => self.thumbs.insert(key, image),
Event::ThumbnailMissing { key } => self.thumbs.mark_missing(key),
Event::Poster { key, image } => self.posters.insert(key, image),
Event::PosterMissing { key } => self.posters.mark_missing(key),
Event::Metadata(records) => {
// Artwork keyed by a title whose record changed may now
// point somewhere else, and a title that was a miss may now
// have a poster to ask for — so those are forgotten. Only
// those: clearing the lot sent every tile in the library
// back to its initials after each match run, to fade the
// very same picture in again.
for key in changed_art(&self.metadata, &records) {
self.posters.forget(&key);
}
self.metadata = records;
}
Event::EpisodeMetadata(episodes) => self.episodes = episodes,
Event::MetadataConfig(config) => {
self.settings = config;
self.posters.clear();
// Turning enrichment off, or switching provider, clears the
// stored answers — including these, which would otherwise
// go on naming episodes after the provider that named them
// was dropped.
self.episodes.clear();
}
Event::Matched {
matched,
unmatched,
failed,
} => {
let mut text = format!("matched {matched}, no match for {unmatched}");
if failed > 0 {
// Worth its own clause: a failure is not a miss, and
// those titles will be asked about again.
text.push_str(&format!(", {failed} could not be looked up"));
}
self.note(ctx, text, failed > 0);
}
Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was
// out — so an answer for a title that is not the open one is
// dropped rather than shown under the wrong heading.
Event::MatchOptions { title_key, options } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.results = options;
matcher.searching = false;
matcher.asked = true;
matcher.error = None;
}
}
Event::MatchSearchFailed { title_key, error } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.searching = false;
matcher.error = Some(error);
}
}
Event::PlaybackReady { target, stream } => {
self.opening = None;
// Dropped before the new one is built: two mpv cores would
// fight over the ring and the bandwidth, and the old one's
// render context has to be freed on this thread anyway.
//
// Both halves of this are blocking calls into mpv on the UI
// thread — destroying a core waits for its demuxer, and
// creating one compiles shaders — so both are timed. If the
// window ever stops answering while episodes change, this is
// the line that says which half did it.
let swap = std::time::Instant::now();
self.playback = None;
let torn_down = swap.elapsed();
let gl = frame.gl().cloned();
let started = Playback::start(&self.engine, *target, stream, gl.as_ref());
if swap.elapsed() >= STALL {
tracing::warn!(
"swapping players held the ui thread for {} ms, {} ms of it \
destroying the previous mpv core",
swap.elapsed().as_millis(),
torn_down.as_millis()
);
}
match started {
Ok(playback) => {
self.overlay = ui::player::Overlay::default();
self.page = Page::Player;
self.playback = Some(playback);
}
Err(error) => self.note(ctx, error, true),
}
}
// Only from the player currently on the bar: one being replaced
// goes on reporting for a moment after its successor started.
Event::Player { id, event } => {
let mine = self.playback.as_ref().is_some_and(|p| p.id == id);
if let Some(playback) = self.playback.as_mut().filter(|p| p.id == id) {
playback.apply(&event);
}
// Played to the end, rather than stopped or failed: the one
// ending that means "and now the next one".
if mine
&& matches!(
event,
pstr_player::PlayerEvent::EndFile(pstr_player::EndReason::Eof)
)
{
self.autoplay_next(ctx);
}
}
Event::PlayerStopped { id } => {
if self.playback.as_ref().is_some_and(|p| p.id == id) {
// Dropping this frees the mpv render context, which has
// to happen on this thread with the GL context current.
// `pump` is called from `ui`, which is exactly that.
let target = self.playback.take().map(|p| p.target.title_key.clone());
// Nothing to show on the player page any more. Back to
// where the click came from, rather than a black window
// — unless the next episode is already opening, which is
// exactly where the viewer wants to stay.
if self.page == Page::Player && self.opening.is_none() {
self.page = match target {
Some(key) => Page::Title(key),
None => Page::Library,
};
}
}
}
Event::Watched {
share_id,
link_id,
state,
} => {
if self.library.set_watch(&share_id, &link_id, state) {
self.view.stale = true;
}
}
Event::Error(text) => {
self.opening = None;
self.note(ctx, text, true);
}
Event::Status(text) => self.note(ctx, text, false),
Event::Downloads(downloads) => self.downloads = downloads,
Event::DownloadProgress(item) => {
if let Some(row) = self.downloads.iter_mut().find(|row| row.key == item.key) {
*row = *item;
}
}
Event::OfflineFiles(files) => self.offline_files = files,
}
}
// Pictures become textures a few per frame; the rest wait for the next.
let thumbs_waiting = self.thumbs.upload(ctx);
let posters_waiting = self.posters.upload(ctx);
if thumbs_waiting || posters_waiting {
ctx.request_repaint();
}
}
/// The playing title's artwork as a `file://` URL, if it is on disk.
///
/// The cached file rather than the provider's URL: the desktop would
/// otherwise fetch it from the provider itself, and a desktop shell is not
/// something the viewer agreed would talk to one.
fn media_cover(&mut self) -> Option<String> {
let playback = self.playback.as_ref()?;
if let Some((id, cover)) = &self.media_cover
&& *id == playback.id
{
return cover.clone();
}
let cover = self
.metadata
.get(&playback.target.title_key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| {
found
.poster_url
.as_deref()
.or_else(|| found.tile_art().map(|(url, _)| url))
})
.and_then(|url| self.engine.poster_file(url))
.map(|path| format!("file://{}", path.display()));
self.media_cover = Some((playback.id, cover.clone()));
cover
}
fn share_name(&self, id: &str) -> String {
self.shares
.iter()
.find(|share| share.id == id)
.map(|share| share.name.clone())
.unwrap_or_else(|| id.to_string())
}
/// The file before or after what is playing, within its own title.
///
/// `None` at either end, and for a title that has been recrawled out from
/// under the player — both are "there is nothing to step to", which is all
/// the caller does with it.
pub fn adjacent(&self, direction: Adjacent) -> Option<PlaybackTarget> {
let target = &self.playback.as_ref()?.target;
let title = self.library.get(&target.title_key)?;
let episode = match direction {
Adjacent::Previous => title.preceding(&target.share_id, &target.link_id),
Adjacent::Next => title.following(&target.share_id, &target.link_id),
}?;
Some(PlaybackTarget::new(title, episode))
}
/// What the provider calls the episode this target is, if anything.
fn episode_name(&self, target: &PlaybackTarget) -> Option<String> {
let number = target.number?;
self.episodes
.get(&target.title_key)?
.get(target.season, number)?
.name
.clone()
}
/// Whether there is anything to step to in either direction, for the
/// controls that offer it.
fn neighbours(&self) -> ui::transport::Neighbours {
ui::transport::Neighbours {
previous: self.adjacent(Adjacent::Previous).is_some(),
next: self.adjacent(Adjacent::Next).is_some(),
}
}
/// Advance the end-of-episode countdown, and say what the card should show.
///
/// Called once a frame from the player page, *before* it is drawn, because
/// drawing does not mutate — the card is told how many seconds are left and
/// nothing more. Returns `None` whenever there is nothing to offer, which
/// is nearly always.
///
/// Four things have to hold before a viewer is interrupted, and each of
/// them is a way this has gone wrong elsewhere: the file has to actually be
/// in its credits, there has to be a next episode to go to, autoplay has to
/// be on, and playback has to be *running* — a paused episode is one
/// somebody walked away from, and coming back to the next one already
/// playing is the opposite of helpful.
fn tick_up_next(
&mut self,
ctx: &egui::Context,
actions: &mut Vec<Action>,
) -> Option<UpNextCard> {
let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next;
let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else {
self.up_next = UpNext::default();
return None;
};
if self.up_next.playback_id != playback.id {
self.up_next = UpNext {
playback_id: playback.id,
..UpNext::default()
};
}
let counting = playback.loaded
&& !playback.paused
&& playback.in_credits()
&& has_next
&& autoplay
&& !self.up_next.dismissed;
if !counting {
self.up_next.started = None;
return None;
}
let now = ctx.input(|input| input.time);
let started = *self.up_next.started.get_or_insert(now);
let left = UP_NEXT_SECONDS - (now - started);
if left <= 0.0 {
// Latched, not cleared: the next file takes a moment to open, and
// without this the countdown would fire again on every frame of
// that moment.
self.up_next.dismissed = true;
actions.push(Action::PlayAdjacent(Adjacent::Next));
return None;
}
// Nothing else causes a frame while a film plays and the mouse is
// still, and a countdown that only ticks when the pointer moves is
// worse than none.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
let still = next.as_ref().and_then(|target| {
let title = self.library.get(&target.title_key)?;
let episode = title.episodes().find(|episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
})?;
ui::Art {
engine: &self.engine,
thumbs: &mut self.thumbs,
posters: &mut self.posters,
metadata: &self.metadata,
episodes: &self.episodes,
}
.still(&title.key, episode)
});
Some(UpNextCard {
seconds: left,
left: (left / UP_NEXT_SECONDS) as f32,
caption: next.map(|target| target.caption()).unwrap_or_default(),
still,
})
}
/// Near the end of a file, fetch the opening blocks of the next one.
///
/// The last two minutes, or the credits if the chapters say where they
/// are — whichever comes first. Late enough that a viewer who stops after
/// one episode has not cost a download, early enough that the swap starts
/// from the cache.
fn warm_next(&mut self) {
/// How close to the end counts as "about to want the next one".
const LEAD_SECONDS: f64 = 120.0;
let Some(playback) = &self.playback else {
return;
};
if playback.id == self.warmed || !playback.loaded {
return;
}
let near_end = playback
.duration
.is_some_and(|duration| duration - playback.position < LEAD_SECONDS);
if !(near_end || playback.in_credits()) {
return;
}
self.warmed = playback.id;
if let Some(next) = self.adjacent(Adjacent::Next) {
self.engine.warm(&next);
}
}
/// Seek past an opening or credits on arrival, when the viewer asked for
/// that.
///
/// Once per chapter per file. A viewer who skipped back into the opening
/// wants to hear it.
fn auto_skip(&mut self, ctx: &egui::Context) {
let Some(playback) = &self.playback else {
return;
};
if !playback.loaded || playback.paused || playback.seeking {
return;
}
let Some((label, end)) = playback.skippable() else {
return;
};
let Some(index) = playback.chapter().map(|chapter| chapter.index) else {
return;
};
if self.auto_skipped.0 != playback.id {
self.auto_skipped = (playback.id, Vec::new());
}
if self.auto_skipped.1.contains(&index) || !self.engine.playback_prefs().auto_skip {
return;
}
self.auto_skipped.1.push(index);
playback.send(crate::playback::Command::SeekTo(end));
// Said, because a jump nobody asked for this second reads as a glitch.
let what = label.trim_start_matches("Skip ").to_lowercase();
self.note(ctx, format!("skipped the {what}"), false);
}
/// Start the next episode, if there is one and the viewer wants it.
///
/// Called on a clean end of file only. A file that failed to load has an
/// 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.engine.playback_prefs().autoplay_next {
return;
}
let Some(target) = self.adjacent(Adjacent::Next) else {
return;
};
self.note(ctx, format!("next: {}", target.caption()), false);
self.apply(ctx, Action::Play(target));
}
fn send_player(&self, command: crate::playback::Command) {
if let Some(playback) = &self.playback {
playback.send(command);
}
}
/// Set the volume everywhere it is kept: mpv, and — when the change is
/// settled rather than mid-drag — the preferences the next player is built
/// from.
///
/// Moving the slider off zero unmutes, because a slider that visibly
/// changes while nothing gets louder is a bug from the viewer's side.
fn set_volume(&mut self, volume: f64, commit: bool) {
let mut prefs = self.engine.playback_prefs();
prefs.volume = volume;
self.send_player(crate::playback::Command::SetVolume(volume));
let muted = self.playback.as_ref().map_or(prefs.muted, |p| p.muted);
if muted && volume > 0.0 {
prefs.muted = false;
self.send_player(crate::playback::Command::SetMuted(false));
}
self.engine.set_playback_prefs(prefs, commit);
}
fn apply(&mut self, ctx: &egui::Context, action: Action) {
match action {
Action::Goto(page) => self.page = page,
Action::Play(mut target) => {
// One player at a time. The old one is only *asked* to stop
// here; it is dropped when its replacement is ready, so a click
// that turns out not to open leaves the current film playing.
if let Some(playback) = &self.playback {
playback.send(crate::playback::Command::Stop);
}
// The one place every route into playback passes through — a
// click on a row, a keypress, autoplay — so the episode's name
// is looked up here rather than in each of them.
target.episode_name = self.episode_name(&target);
target.track_prefs = self
.engine
.title_track_prefs(&target.title_key)
.map(Box::new);
self.opening = Some(target.name.clone());
self.engine.play(target);
}
Action::MakeOffline(targets) => {
self.note(ctx, "downloading for offline use…", false);
self.engine.make_offline(targets);
}
Action::PauseDownload(key) => self.engine.pause_download(&key),
Action::ResumeDownload(key) => self.engine.resume_download(&key),
Action::CancelDownload(key) => self.engine.cancel_download(&key),
Action::RemoveDownload(key, remove_partial) => {
if remove_partial {
self.confirm_partial_delete = Some(key);
} else {
self.engine.remove_download(key, false);
}
}
Action::SetMetadataConfig(config) => {
self.settings = config.clone();
self.engine.set_metadata_config(config);
}
Action::SetApiKey { provider, key } => self.engine.set_api_key(provider, key),
Action::MatchTitles { force } => {
if self.library.is_empty() {
self.note(ctx, "crawl a share first — there is nothing to match", true);
} else {
self.matching = true;
self.engine.match_titles(self.library.titles.clone(), force);
}
}
Action::OpenMatcher(key) => {
self.matcher = self.library.get(&key).map(Matcher::new);
// The library's own name is what the automatic search already
// failed on, so the first search is the viewer's to press —
// except that pressing it unchanged is exactly what someone
// wants when the *scorer* was the problem rather than the name.
// So it is sent, and the box stays theirs to edit.
if self.matcher.is_some() {
self.apply(ctx, Action::SearchMatches);
}
}
Action::CloseMatcher => self.matcher = None,
Action::SearchMatches => {
if let Some(matcher) = &mut self.matcher {
matcher.searching = true;
matcher.error = None;
self.engine.search_matches(
matcher.title_key.clone(),
matcher.query.clone(),
matcher.kind,
);
}
}
Action::ChooseMatch(found) => {
if let Some(title) = self
.matcher
.as_ref()
.and_then(|matcher| self.library.get(&matcher.title_key))
{
self.engine.choose_match(title.clone(), *found);
}
self.matcher = None;
}
Action::ForgetMatch(key) => {
self.engine.forget_match(key);
self.matcher = None;
}
Action::ToggleFullscreen => {
self.fullscreen = !self.fullscreen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
}
Action::WatchToEnd => self.up_next.dismissed = true,
Action::FocusSearch => {
if !matches!(self.page, Page::Library | Page::Title(_)) {
self.page = Page::Library;
}
self.focus_search = true;
}
Action::ToggleShortcuts => self.shortcuts_open = !self.shortcuts_open,
Action::SetShelf(filter, sort) => {
self.view.filter = filter;
self.view.sort = sort;
}
Action::Back => match self.page {
Page::Title(_) => self.page = Page::Library,
// Back out of a search before out of anything else: it is the
// last thing the viewer did.
Page::Library if !self.search.is_empty() => self.search.clear(),
_ => {}
},
Action::LeavePlayer => {
if self.fullscreen {
self.fullscreen = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
}
self.page = match &self.playback {
Some(playback) => Page::Title(playback.target.title_key.clone()),
None => Page::Library,
};
}
Action::Crawl(share) => {
self.crawling = true;
self.note(ctx, "crawling…", false);
self.engine.crawl(share);
}
Action::StopCrawl => self.engine.stop_crawl(),
Action::AddShare {
name,
url,
password,
} => {
self.crawling = true;
self.form.sending = true;
self.form.error = None;
self.engine.add_share(name, url, password);
}
Action::RemoveShare(id) => self.confirm_remove_share = Some(id),
Action::ForgetShare(id) => {
self.confirm_remove_share = None;
self.thumbs.clear();
self.engine.remove_share(id);
}
Action::SetWatched {
share_id,
link_id,
watched,
duration,
} => {
// Unwatching rewinds: a file marked unseen that still holds a
// position would come back as "resume at 19:04".
let position = if watched {
duration.unwrap_or(0.0)
} else {
0.0
};
self.engine.save_watch_state(
share_id,
link_id,
watch_state(position, duration, watched),
);
}
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 Some(playback) = &self.playback {
playback.send(command);
}
}
Action::PlayAdjacent(direction) => match self.adjacent(direction) {
Some(target) => self.apply(ctx, Action::Play(target)),
None => self.note(
ctx,
match direction {
Adjacent::Previous => "this is the first one",
Adjacent::Next => "this is the last one",
},
false,
),
},
Action::SetSpeed(speed) => {
let mut prefs = self.engine.playback_prefs();
prefs.speed = speed;
self.engine.set_playback_prefs(prefs, true);
let speed = self.engine.playback_prefs().speed;
if let Some(playback) = &mut self.playback {
playback.speed = speed;
playback.send(crate::playback::Command::SetSpeed(speed));
}
let text = format!("Speed {}", ui::format_speed(speed));
if self.page == Page::Player {
self.flash(ctx, text);
} else {
self.note(ctx, text, false);
}
}
Action::SetAutoSkip(auto_skip) => {
let mut prefs = self.engine.playback_prefs();
prefs.auto_skip = auto_skip;
self.engine.set_playback_prefs(prefs, true);
}
Action::SetAutoplay(autoplay) => {
let mut prefs = self.engine.playback_prefs();
prefs.autoplay_next = autoplay;
self.engine.set_playback_prefs(prefs, true);
}
Action::SetAppearance(appearance) => {
self.engine.set_appearance(appearance);
theme::apply(ctx, appearance);
}
Action::SetVolume { volume, commit } => {
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}%"));
}
}
Action::ToggleMute => {
let mut prefs = self.engine.playback_prefs();
// From what is playing, not from the preferences: mpv is the
// one that knows, and a keypress in its own window changes it
// without going through here.
prefs.muted = match &self.playback {
Some(playback) => !playback.muted,
None => !prefs.muted,
};
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" });
self.engine.set_playback_prefs(prefs, true);
}
Action::SelectTrack { kind, id } => {
self.send_player(crate::playback::Command::SelectTrack(kind, id));
// Remember the language and the track's name, not its number:
// the next episode is a different file, where track 3 may be a
// commentary and Japanese may be track 2. The name is what
// tells "Signs & Songs" from "Full Subtitles" in one language.
let (language, title) = id
.and_then(|id| {
self.playback.as_ref().and_then(|playback| {
playback
.tracks_of(kind)
.find(|track| track.id == id)
.map(|track| (track.language.clone(), track.title.clone()))
})
})
.unwrap_or_default();
if let Some(playback) = &self.playback {
let mut show = self
.engine
.title_track_prefs(&playback.target.title_key)
.unwrap_or_default();
match kind {
pstr_player::TrackKind::Audio => {
show.audio_language = language;
show.audio_title = title;
}
pstr_player::TrackKind::Subtitle => {
show.subtitles = id.is_some();
if id.is_some() {
show.subtitle_language = language;
show.subtitle_title = title;
}
}
pstr_player::TrackKind::Video => {}
}
self.engine
.set_title_track_prefs(playback.target.title_key.clone(), show);
}
}
}
}
/// The bar across the top: where you are, and what to search.
fn navigation(
&self,
ui: &mut egui::Ui,
actions: &mut Vec<Action>,
search: &mut String,
focus_search: bool,
) {
ui.horizontal(|ui| {
ui.add_space(4.0);
ui.label(
theme::Role::Heading
.rich("proton-stream")
.strong()
.color(theme::accent()),
);
ui.add_space(12.0);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
let active = self
.downloads
.iter()
.filter(|download| {
matches!(
download.state,
crate::engine::DownloadState::Queued
| crate::engine::DownloadState::Running
| crate::engine::DownloadState::Paused
)
})
.count();
let downloads = if active == 0 {
"Downloads".to_owned()
} else {
format!("Downloads ({active})")
};
let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings];
let clicked = ui::tabs(
ui,
ui.id().with("nav"),
&[
("Library", on_library),
("Shares", self.page == Page::Shares),
(&downloads, self.page == Page::Downloads),
("Settings", self.page == Page::Settings),
],
);
if let Some(index) = clicked {
actions.push(Action::Goto(pages[index].clone()));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
if self.crawling {
if ui
.small_button("Stop")
.on_hover_text("Stop listing; shares already listed are still stored")
.clicked()
{
actions.push(Action::StopCrawl);
}
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(crawl_label(&self.crawl_progress)));
} else if self.matching {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("matching"));
} else if self.connecting {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("connecting"));
} else if ui
.button("Refresh")
.on_hover_text("Re-crawl every share")
.clicked()
{
actions.push(Action::Crawl(None));
}
if on_library && !self.library.is_empty() {
ui.add_space(8.0);
ui::search_field(ui, search, focus_search);
}
});
});
}
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
// Before the timer: this is the frame budget being spent waiting, not
// the frame taking longer, and `FrameTimer` reports slow frames.
self.pacer.wait();
let _timer = FrameTimer::new(&self.page);
let ctx = ui.ctx().clone();
let ctx = &ctx;
self.pump(ctx, frame);
self.warm_next();
self.auto_skip(ctx);
self.window.observe(ctx);
let mut actions: Vec<Action> = Vec::new();
self.media.drain(self.playback.as_ref(), &mut actions);
let cover = self.media_cover();
self.media.show(self.playback.as_ref(), cover.as_deref());
// A player page with nothing playing and nothing opening is a black
// window with no way out. It should be unreachable — `pump` routes away
// when playback stops — but the failure mode is bad enough to guard.
if self.page == Page::Player && self.playback.is_none() && self.opening.is_none() {
self.page = Page::Library;
}
self.engine.set_picture_shown(self.page == Page::Player);
// On screen and moving: a film paused, or playing behind another page,
// is no reason to keep the screen awake.
self.inhibitor.set(
self.page == Page::Player
&& self
.playback
.as_ref()
.is_some_and(|playback| playback.loaded && !playback.paused),
);
if self.page == Page::Player {
self.overlay.observe(ctx);
let state = self.playback.as_ref().map(|playback| PlayerKeys {
volume: playback.volume,
speed: playback.speed,
skip_to: playback.skippable().map(|(_, end)| end),
});
shortcuts(ctx, self.fullscreen, 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.
let up_next = self.tick_up_next(ctx, &mut actions);
let neighbours = self.neighbours();
let App {
playback,
opening,
overlay,
..
} = self;
// 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.
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
.show(ui, |ui| {
ui::player::show(
ui,
frame,
playback.as_mut(),
opening.as_deref(),
ui::player::Chrome {
overlay,
up_next,
neighbours,
},
&mut actions,
);
});
if let Some((text, at)) = &self.osd
&& !ui::player::osd(ctx, text, *at)
{
self.osd = None;
}
self.toasts.show(ctx, self.overlay.covered());
for action in actions {
self.apply(ctx, action);
}
return;
}
// Not under a dialog: Escape there closes the dialog, and should not
// also leave the page behind it.
let dialog = self.matcher.is_some()
|| self.confirm_partial_delete.is_some()
|| self.confirm_remove_share.is_some()
|| self.shortcuts_open;
if !dialog {
global_shortcuts(ctx, &self.page, &mut actions);
}
// Split the borrow up front: the pages get read-only state and a place
// to put actions, which is what keeps them from mutating mid-draw.
{
let mut search = std::mem::take(&mut self.search);
let neighbours = self.neighbours();
let playback_prefs = self.engine.playback_prefs();
let prefs = ui::settings::Prefs {
autoplay: playback_prefs.autoplay_next,
auto_skip: playback_prefs.auto_skip,
appearance: self.engine.appearance(),
};
const NAV_MARGIN: egui::Vec2 = egui::vec2(14.0, 10.0);
egui::Panel::top("nav")
.resizable(false)
// No fill: the bar is a gradient, and a `Frame` only takes a
// colour. It is painted below, into a shape reserved before the
// contents are laid out — which is the only point at which the
// height they came to is known.
.frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(
NAV_MARGIN.x as i8,
NAV_MARGIN.y as i8,
)))
.show(ui, |ui| {
let shadow = ui.painter().add(egui::Shape::Noop);
let background = ui.painter().add(egui::Shape::Noop);
self.navigation(ui, &mut actions, &mut search, self.focus_search);
// Full width from the space the panel was given, height from
// the space its contents took.
let content = ui.min_rect();
let bar = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), content.top()),
egui::pos2(ui.max_rect().right(), content.bottom()),
)
.expand2(NAV_MARGIN);
ui.painter()
.set(background, theme::bar_shape(ui.ctx(), bar));
// Past the panel's own edge, or the half of the shadow that
// falls on the page below it — the only half worth drawing
// — is clipped away.
ui.painter().with_clip_rect(ui.ctx().viewport_rect()).set(
shadow,
theme::bar_shadow(true).as_shape(bar, egui::CornerRadius::ZERO),
);
});
self.search = search;
self.focus_search = false;
self.view.refresh(&self.library, &self.search);
let App {
engine,
page,
library,
shares,
thumbs,
posters,
metadata,
episodes,
settings,
api_key,
search,
view,
loaded,
form,
matcher,
playback,
opening,
downloads,
offline_files,
confirm_partial_delete,
confirm_remove_share,
..
} = self;
let mut covered = 0.0;
if playback.is_some() || opening.is_some() {
covered = egui::Panel::bottom("transport")
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::symmetric(16, 10)),
)
.show(ui, |ui| {
// Above the bar rather than below it: this one is at the
// bottom of the window, so the page it separates itself
// from is the one over it.
//
// Clipped to the strip it falls on, because a `Frame`
// paints its fill *under* its contents and a shadow
// added here would otherwise lay its opaque middle over
// the bar it is cast by.
let bar = ui.max_rect().expand2(egui::vec2(16.0, 10.0));
let above = egui::Rect::from_min_max(
egui::pos2(bar.left(), bar.top() - 16.0),
egui::pos2(bar.right(), bar.top()),
);
ui.painter()
.with_clip_rect(above)
.add(theme::bar_shadow(false).as_shape(bar, egui::CornerRadius::ZERO));
transport_panel(
ui,
playback.as_ref(),
opening.as_deref(),
neighbours,
&mut actions,
);
})
.response
.rect
.height();
}
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)),
)
.show(ui, |ui| {
// Reborrowed rather than moved: the matching dialog below is
// drawn from the same caches, and a moved `&mut` would leave
// nothing to draw it with.
let mut art = ui::Art {
engine,
thumbs: &mut *thumbs,
posters: &mut *posters,
metadata,
episodes,
};
match page {
Page::Library => ui::library::show(
ui,
&mut art,
ui::library::Shelves {
library,
view,
loaded: *loaded,
search,
},
&mut actions,
),
Page::Title(key) => ui::title::show(
ui,
&mut art,
library,
key,
ui::title::OfflineView {
downloads,
files: offline_files,
},
&mut actions,
),
Page::Shares => ui::shares::show(ui, shares, library, form, &mut actions),
Page::Settings => {
ui::settings::show(ui, settings, prefs, api_key, &mut actions)
}
Page::Downloads => ui::downloads::show(ui, downloads, &mut actions),
// Drawn above, without any of these panels.
Page::Player => {}
}
});
// Over everything, and after it: a modal takes the input the page
// under it would otherwise get, and it can only do that for widgets
// laid out after it claims the layer.
if let Some(open) = matcher {
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
};
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
}
if let Some(key) = confirm_partial_delete.clone() {
match ui::confirm(
ctx,
"partial",
"Delete the partial download?",
"What has been downloaded so far is discarded. The file in the share is \
not touched.",
"Delete",
) {
ui::Answer::Confirmed => {
engine.remove_download(key, true);
*confirm_partial_delete = None;
}
ui::Answer::Declined => *confirm_partial_delete = None,
ui::Answer::Pending => {}
}
}
if let Some(id) = confirm_remove_share.clone() {
let name = shares
.iter()
.find(|share| share.id == id)
.map_or(id.as_str(), |share| share.name.as_str());
match ui::confirm(
ctx,
"remove-share",
&format!("Remove {name}?"),
"Its titles leave the library, along with what you have watched of them. \
Its downloads are deleted, and the link and password are removed from the \
keyring.",
"Remove",
) {
ui::Answer::Confirmed => actions.push(Action::ForgetShare(id)),
ui::Answer::Declined => *confirm_remove_share = None,
ui::Answer::Pending => {}
}
}
if self.shortcuts_open && ui::shortcuts::show(ctx) {
actions.push(Action::ToggleShortcuts);
}
self.toasts.show(ctx, covered);
}
for action in actions {
self.apply(ctx, action);
}
}
/// Save where playback got to before the window goes away. mpv is still
/// running at this point, so this is the last chance to ask it.
///
/// The player is then dropped *here* rather than left to the app's own
/// destructor: dropping it frees the mpv render context, which has to
/// happen while the OpenGL context is still current. eframe calls this
/// immediately before tearing the painter down, which is the last moment
/// that is true.
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.window.save();
if let Some(playback) = &self.playback {
playback.stop_and_wait(std::time::Duration::from_secs(2));
}
self.playback = None;
self.engine
.shutdown_downloads(std::time::Duration::from_secs(3));
}
}
/// The native window, for the parts of the OS that want it named — SMTC on
/// Windows. Nothing else asks.
#[cfg(windows)]
fn window_handle(cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> {
use raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get() as *mut std::ffi::c_void),
_ => None,
}
}
#[cfg(not(windows))]
fn window_handle(_cc: &eframe::CreationContext<'_>) -> Option<*mut std::ffi::c_void> {
None
}
/// What the top bar says while shares are being listed: one share by name,
/// several as a count, with the files found across all of them.
fn crawl_label(progress: &std::collections::BTreeMap<String, usize>) -> String {
let found: usize = progress.values().sum();
let which = match progress.len() {
0 => return "crawling".to_owned(),
1 => progress.keys().next().cloned().unwrap_or_default(),
shares => format!("{shares} shares"),
};
if found == 0 {
format!("crawling {which}")
} else {
format!("crawling {which} · {found} found")
}
}
/// Titles whose tile art is not what it was.
///
/// Compared by URL: the same URL is the same picture, and anything else —
/// a different one, a new one, or none any more — has to be fetched or
/// dropped.
fn changed_art(
before: &HashMap<String, MetadataRecord>,
after: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
let url = |records: &HashMap<String, MetadataRecord>, key: &str| {
records
.get(key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.tile_art())
.map(|(url, _)| url.to_owned())
};
let mut keys: Vec<String> = before
.keys()
.chain(after.keys())
.filter(|key| url(before, key) != url(after, key))
.cloned()
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
/// Keys every page but the player answers to.
///
/// Plain keys — `/`, `?` — only while nothing is being typed into, or a search
/// for "a/b" would jump to the search box halfway through it.
fn global_shortcuts(ctx: &egui::Context, page: &Page, actions: &mut Vec<Action>) {
use egui::{Key, Modifiers};
let typing = ctx.egui_wants_keyboard_input();
let mut tab = None;
ctx.input_mut(|input| {
if input.consume_key(Modifiers::COMMAND, Key::F)
|| (!typing && input.key_pressed(Key::Slash))
{
actions.push(Action::FocusSearch);
}
if input.consume_key(Modifiers::NONE, Key::F5)
|| input.consume_key(Modifiers::COMMAND, Key::R)
{
actions.push(Action::Crawl(None));
}
if !typing && input.key_pressed(Key::Questionmark) {
actions.push(Action::ToggleShortcuts);
}
if input.consume_key(Modifiers::ALT, Key::ArrowLeft)
|| input.pointer.button_pressed(egui::PointerButton::Extra1)
|| (!typing && matches!(page, Page::Title(_)) && input.key_pressed(Key::Escape))
|| (!typing && *page == Page::Library && input.key_pressed(Key::Backspace))
{
actions.push(Action::Back);
}
for (key, index) in [
(Key::Num1, 0),
(Key::Num2, 1),
(Key::Num3, 2),
(Key::Num4, 3),
] {
if input.consume_key(Modifiers::COMMAND, key) {
tab = Some(index);
}
}
if input.consume_key(Modifiers::COMMAND, Key::Comma) {
tab = Some(3);
}
});
if let Some(index) = tab {
let page = [Page::Library, Page::Shares, Page::Downloads, Page::Settings][index].clone();
actions.push(Action::Goto(page));
}
}
/// Keys the player page answers to.
///
/// Only there: elsewhere space is a button press and the arrows move between
/// widgets, and taking them would break the rest of the app to serve a page
/// that is not on screen.
/// What the player keys need to know about what is playing.
struct PlayerKeys {
volume: f64,
speed: f64,
/// Where the skip button would go, while there is one.
skip_to: Option<f64>,
}
/// 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];
/// The next step from `speed` in `direction`, from wherever it is now.
fn step_speed(speed: f64, faster: bool) -> f64 {
if faster {
SPEEDS
.iter()
.copied()
.find(|&step| step > speed + 1e-6)
.unwrap_or(SPEEDS[SPEEDS.len() - 1])
} else {
SPEEDS
.iter()
.rev()
.copied()
.find(|&step| step < speed - 1e-6)
.unwrap_or(SPEEDS[0])
}
}
fn shortcuts(
ctx: &egui::Context,
fullscreen: bool,
playing: Option<PlayerKeys>,
actions: &mut Vec<Action>,
) {
use crate::playback::Command;
use egui::Key;
/// What one press of the volume keys is worth. mpv's own step, and small
/// enough that holding the key is a ramp rather than a switch.
const VOLUME_STEP: f64 = 5.0;
let pressed: Vec<Key> = ctx.input(|input| {
[
Key::Space,
Key::K,
Key::ArrowLeft,
Key::ArrowRight,
Key::ArrowUp,
Key::ArrowDown,
Key::M,
Key::N,
Key::P,
Key::F,
Key::S,
Key::OpenBracket,
Key::CloseBracket,
Key::Escape,
]
.into_iter()
.filter(|key| input.key_pressed(*key))
.collect()
});
let volume = playing.as_ref().map_or(0.0, |playing| playing.volume);
for key in pressed {
match key {
Key::Space | Key::K => actions.push(Action::Player(Command::TogglePause)),
Key::ArrowLeft => actions.push(Action::Player(Command::SeekBy(-10.0))),
Key::ArrowRight => actions.push(Action::Player(Command::SeekBy(30.0))),
// Written straight to the preferences rather than debounced: a
// keypress is one change, not a drag.
Key::ArrowUp => actions.push(Action::SetVolume {
volume: (volume + VOLUME_STEP).min(pstr_player::MAX_VOLUME),
commit: true,
}),
Key::ArrowDown => actions.push(Action::SetVolume {
volume: (volume - VOLUME_STEP).max(0.0),
commit: true,
}),
Key::M => actions.push(Action::ToggleMute),
Key::N => actions.push(Action::PlayAdjacent(Adjacent::Next)),
Key::P => actions.push(Action::PlayAdjacent(Adjacent::Previous)),
Key::F => actions.push(Action::ToggleFullscreen),
Key::S => {
if let Some(end) = playing.as_ref().and_then(|playing| playing.skip_to) {
actions.push(Action::Player(Command::SeekTo(end)));
}
}
Key::OpenBracket | Key::CloseBracket => {
if let Some(playing) = &playing {
actions.push(Action::SetSpeed(step_speed(
playing.speed,
key == Key::CloseBracket,
)));
}
}
// Escape leaves fullscreen first and the page second, which is what
// it does everywhere else and what stops one press from both
// un-maximising the window and hiding the film.
Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen),
Key::Escape => actions.push(Action::LeavePlayer),
_ => {}
}
}
}
/// The transport bar, or the line that says a file is still opening.
fn transport_panel(
ui: &mut egui::Ui,
playback: Option<&Playback>,
opening: Option<&str>,
neighbours: ui::transport::Neighbours,
actions: &mut Vec<Action>,
) {
match playback {
Some(playback) => ui::transport::mini(ui, playback, neighbours, actions),
None => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(ui::muted(format!(
"opening {}…",
opening.unwrap_or("the file")
)));
});
}
}
}
#[cfg(test)]
mod tests {
use pstr_core::library::TitleKind;
use pstr_core::metadata::{ProviderId, TitleMetadata};
use super::*;
fn record(key: &str, poster: Option<&str>) -> (String, MetadataRecord) {
let metadata = poster.map(|url| TitleMetadata {
provider: ProviderId::AniList,
remote_id: key.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: Some(url.into()),
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
});
(
key.into(),
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata,
fetched_at: 0,
manual: false,
},
)
}
#[test]
fn the_filters_split_the_library_by_kind() {
use pstr_core::library::{Title, TitleKind};
let title = |kind| Title {
key: "k".into(),
name: "n".into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
};
let film = title(TitleKind::Film);
let series = title(TitleKind::Series);
assert!(Filter::All.admits(&film) && Filter::All.admits(&series));
assert!(Filter::Films.admits(&film) && !Filter::Films.admits(&series));
assert!(Filter::Series.admits(&series) && !Filter::Series.admits(&film));
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film));
assert!(!Filter::Watching.admits(&film));
}
#[test]
fn the_crawl_label_names_one_share_and_counts_several() {
let mut progress = std::collections::BTreeMap::new();
assert_eq!(crawl_label(&progress), "crawling");
progress.insert("anime".to_owned(), 0);
assert_eq!(crawl_label(&progress), "crawling anime");
progress.insert("films".to_owned(), 40);
progress.insert("anime".to_owned(), 2);
assert_eq!(crawl_label(&progress), "crawling 2 shares · 42 found");
}
#[test]
fn speed_steps_land_back_on_normal() {
assert_eq!(step_speed(1.0, true), 1.25);
assert_eq!(step_speed(1.25, false), 1.0);
// From somewhere between steps — a speed set on Android — to the
// nearest step in the direction asked.
assert_eq!(step_speed(1.1, true), 1.25);
assert_eq!(step_speed(1.1, false), 1.0);
assert_eq!(step_speed(3.0, true), 3.0);
assert_eq!(step_speed(0.5, false), 0.5);
}
#[test]
fn a_match_run_that_changes_nothing_keeps_every_poster() {
let before: HashMap<_, _> = [record("a", Some("x")), record("b", None)].into();
let after = before.clone();
assert!(changed_art(&before, &after).is_empty());
}
#[test]
fn only_titles_whose_art_moved_lose_their_poster() {
let before: HashMap<_, _> = [
record("same", Some("x")),
record("moved", Some("old")),
record("dropped", Some("y")),
record("found", None),
]
.into();
let after: HashMap<_, _> = [
record("same", Some("x")),
record("moved", Some("new")),
record("found", Some("z")),
record("added", Some("w")),
]
.into();
assert_eq!(
changed_art(&before, &after),
vec!["added", "dropped", "found", "moved"]
);
}
}