//! 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), PauseDownload(DownloadKey), ResumeDownload(DownloadKey), CancelDownload(DownloadKey), RemoveDownload(DownloadKey, bool), /// Crawl one share, or every share. Crawl(Option), /// Stop the crawls still listing. StopCrawl, AddShare { name: String, url: String, password: Option, }, /// 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, }, 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, }, /// 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), /// 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, /// 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, /// 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, /// 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, /// The "Continue watching" shelf, most recent first, as indices. pub resumable: Vec, /// 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, } pub struct App { engine: Engine, events: Receiver, pub page: Page, pub library: Library, pub shares: Vec, /// 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, /// What they said about the episodes under those titles. pub episodes: HashMap, 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, 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, /// 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), /// 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, 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, /// 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)>, /// Holds the screensaver off while a film is on screen and playing. inhibitor: crate::inhibit::Inhibitor, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, /// A share the viewer asked to remove, waiting on their say-so. pub confirm_remove_share: Option, /// 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, dirs: AppDirs, ) -> anyhow::Result { // 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) { 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, 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 { 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 { 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 { 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, ) -> Option { 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, 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 = 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 { 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, after: &HashMap, ) -> Vec { let url = |records: &HashMap, 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 = 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) { 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, } /// 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, actions: &mut Vec, ) { 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 = 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, ) { 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"] ); } }