//! 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, TitleNames}; use pstr_core::mylist::MyList; use crate::engine::{ AccountStatus, 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::History => "history", Page::Title(_) => "title", Page::Browse { .. } => "browse", 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; /// A file asked for whose player does not exist yet. pub struct Opening { /// What the bar and the player page call it while it opens. pub name: String, pub title_key: String, /// The page the viewer asked from, to go back to if it never opens. The /// player page itself when one episode follows another. pub from: Page, } /// Which page is showing. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Page { Library, /// Everything played, newest first. History, /// One title, by [`pstr_core::library::Title::key`]. Title(String), /// The titles a genre, studio or person chip stands for, with the title /// it was opened from, for the way back. Browse { facet: pstr_core::discover::Facet, from: Option, }, 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, } /// How often, in seconds, an open window looks for shares due a crawl. What /// counts as due is [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`]. const STALE_CHECK_EVERY: f64 = 30.0 * 60.0; /// The pages in the top bar, in order. Ctrl+1 to Ctrl+5 go by position here. const NAV: [Page; 5] = [ Page::Library, Page::History, Page::Shares, Page::Downloads, Page::Settings, ]; /// Something a click asked for, applied once the frame is drawn. pub enum Action { Goto(Page), Play(PlaybackTarget), /// Play a file from wherever the library says it was left, for a caller /// whose target may be older than that — a download queued last week. PlayResuming(PlaybackTarget), /// Download one or more files as complete local copies. MakeOffline(Vec), PauseDownload(DownloadKey), ResumeDownload(DownloadKey), CancelDownload(DownloadKey), RemoveDownload(DownloadKey, bool), /// Ask whether to delete finished offline copies — of one episode, a /// season or a show, named by the text. DeleteOffline(Vec, String), /// Crawl one share, or every share. Crawl(Option), /// Stop the crawls still listing. StopCrawl, /// Save the frame on screen to the pictures folder. Screenshot, /// A share link was pasted outside the form: open the form with it. PasteShare(String), /// Go to the add form, caret in the link field. OpenShareForm, /// Stop offering posters and synopses on the library page. DismissMetadataOffer, /// Call a share something else. RenameShare { id: String, name: String, }, /// Store a new link, and password, for a share already in the library. RepairShare { id: String, url: String, password: Option, }, AddShare { name: String, url: String, password: Option, }, /// Add a folder of the signed-in account as a share. AddAccountFolder { name: String, uid: pstr_core::proton_sdk::ids::NodeUid, }, SignIn { username: String, password: String, }, SubmitSecondFactor(String), SubmitMailboxPassword(String), CancelSignIn, SignOut, /// Sync watch history with the account's Drive now. SyncNow, /// List the account's top-level places in the Drive browser. BrowsePlaces, /// List one folder in the Drive browser. BrowseFolder(pstr_core::proton_sdk::ids::NodeUid), /// 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), /// The audio and subtitle languages a file is opened with, for titles /// without a choice of their own. `subtitles` false is "off". SetLanguages { audio: Option, subtitles: bool, subtitle: Option, }, /// How subtitles look, for the file playing and every one after. Written /// to the preferences once the mouse is let go; see /// [`App::save_subtitle_style`]. SetSubtitleStyle(pstr_core::prefs::SubtitleStyle), /// Shift the playing file's subtitles by this many seconds more. NudgeSubtitleDelay(f64), /// Put the playing file's subtitles back on the file's own timing. ResetSubtitleDelay, /// Play faster or slower, and keep doing so for the next file. SetSpeed(f64), /// Seek past openings and credits without being asked. SetAutoSkip(bool), SetDesktopPrefs(crate::desktop_prefs::DesktopPrefs), /// Every episode of a title, watched or not, with an Undo. SetTitleWatched { key: String, watched: bool, }, /// Some episodes of a title — a season, or everything up to one — /// watched or not, with an Undo. `what` names them in the toast. MarkEpisodes { key: String, links: Vec<(String, String)>, watched: bool, what: String, }, /// Take a title off Continue watching by forgetting where it stopped, /// with an Undo. ForgetPosition { share_id: String, link_id: String, }, /// Take one file off the history page: forget its position and its /// watched mark, with an Undo. RemoveFromHistory { share_id: String, link_id: String, }, /// Open or close one of the panels beside the picture. TogglePanel(ui::player::PlayerPanel), /// Pause when this runs out, or turn the timer off. SetSleep(Option), /// Put watch states back as they were — what Undo does. RestoreWatch(Vec<(String, String, pstr_core::catalog::WatchState)>), ClearStreamCache, /// 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), /// Fold each franchise into one tile, or show every title. SetGrouped(bool), /// Put a title on My List, or take it off. SetOnList { key: String, on: bool, }, /// 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, /// The card is counting down the last seconds of a file with no credits /// chapter, rather than the credits. Dismissing it then also means "do not /// start the next one", since there are no credits to watch till the end. tail: 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. 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, /// One tile per franchise rather than one per title. See /// `pstr_core::franchise`. pub grouped: bool, /// What `matches` was built with. built_with: Option<(Filter, Sort, bool, TitleNames)>, /// Indices into [`Library::titles`]. pub matches: Vec, /// In a grouped grid, how many other titles a tile stands for, by index. pub folded: HashMap, /// Every title in a franchise of more than one, by key: the keys of the /// whole franchise in release order, its own included. pub franchises: HashMap>, /// The rows above the grid, as a heading and indices. pub shelves: Vec<(String, Vec)>, /// Every played episode, newest first, for the history page. pub history: Vec, /// The "Continue watching" shelf, most recent first, as indices. pub resumable: Vec, /// What each tile says about what arrived — `New`, `3 new` — by key. pub badges: HashMap, /// The keys on My List. pub listed: std::collections::HashSet, /// What the search found besides titles. pub found: pstr_core::discover::Found, /// "More like this" for the title page open, worked out once per title: /// its key, and the keys of the titles like it. pub similar: Option<(String, Vec)>, /// The library or what is known about it changed under the lists. stale: bool, /// Only watch state changed: the shelves and franchises still stand, /// and the grid does unless it is filtered or sorted by watching. progressed: bool, } impl LibraryView { /// The view as the viewer last arranged it. fn arranged(prefs: crate::desktop_prefs::DesktopPrefs) -> Self { Self { filter: prefs.filter, sort: prefs.sort, grouped: prefs.grouped, ..Self::default() } } } impl Default for LibraryView { fn default() -> Self { Self { query: String::new(), filter: Filter::default(), sort: Sort::default(), // On: a share holding a series and its three films is four tiles // of the same picture otherwise. grouped: true, built_with: None, matches: Vec::new(), folded: HashMap::new(), franchises: HashMap::new(), shelves: Vec::new(), history: Vec::new(), resumable: Vec::new(), badges: HashMap::new(), listed: std::collections::HashSet::new(), similar: None, found: pstr_core::discover::Found::default(), stale: false, progressed: false, } } } pub use pstr_core::browse::{Filter, Sort}; /// What the library view is built from, besides the library itself. pub struct ViewInputs<'a> { pub query: &'a str, pub metadata: &'a HashMap, pub names: TitleNames, /// When each title last gained a file, by key. pub added: &'a HashMap, /// The files kept on this computer, for the Downloaded filter. pub offline: &'a std::collections::HashSet, pub list: &'a MyList, /// When each file first appeared in a crawl, for what is new. pub first_seen: &'a HashMap<(String, String), i64>, /// What the providers said about each title's episodes, for searching /// them by name. pub guides: &'a HashMap, pub now: i64, } /// How many shelves the library shows above the grid. const SHELVES: usize = 4; /// How many titles a title page's "More like this" row offers. const SIMILAR: usize = 12; impl LibraryView { fn refresh(&mut self, library: &Library, inputs: ViewInputs<'_>) { use pstr_core::library::Title; let built_with = (self.filter, self.sort, self.grouped, inputs.names); let unchanged = self.query == inputs.query && self.built_with == Some(built_with); // A playing episode saves its position every few seconds. Rebuilding // franchises, shelves and the arranged grid for each save is most of // a frame on a big library, for lists that did not move. let watch_shapes_grid = matches!(self.filter, Filter::Watching | Filter::Unwatched) || self.sort == Sort::Recent; if !self.stale && self.progressed && unchanged && !watch_shapes_grid { self.history = ui::history::entries(library); self.resumable = Self::resumable(library); self.badges = Self::badges(library, &inputs); self.progressed = false; return; } if !self.stale && !self.progressed && unchanged { return; } let metadata = inputs.metadata; if self.stale { self.similar = None; let all: Vec<&Title> = library.titles.iter().collect(); self.franchises = pstr_core::franchise::group(&all, metadata) .into_iter() .filter(|franchise| franchise.members.len() > 1) .flat_map(|franchise| { let keys: Vec = franchise .members .iter() .map(|&index| all[index].key.clone()) .collect(); keys.clone().into_iter().map(move |key| (key, keys.clone())) }) .collect(); let index: HashMap<&str, usize> = library .titles .iter() .enumerate() .map(|(at, title)| (title.key.as_str(), at)) .collect(); self.listed = inputs .list .entries() .map(|(key, _)| key.to_owned()) .collect(); self.shelves = pstr_core::shelves::home( &library.titles, metadata, inputs.names, inputs.list, inputs.first_seen, inputs.now, SHELVES, ) .into_iter() .map(|shelf| { let indices = shelf .keys .iter() .filter_map(|key| index.get(key.as_str()).copied()) .collect(); (shelf.label, indices) }) .collect(); } let titles: Vec<&Title> = pstr_core::browse::search(library, metadata, inputs.query) .into_iter() .filter(|title| { self.filter.admits( title, |node| { inputs.offline.contains(&DownloadKey { share_id: node.share_id.clone(), link_id: node.link_id.clone(), }) }, inputs.list, ) }) .collect(); let tiles = pstr_core::browse::arrange( &titles, pstr_core::browse::Order { sort: self.sort, grouped: self.grouped, names: inputs.names, metadata, added: inputs.added, }, ); let index_of = |title: &Title| { library .titles .iter() .position(|candidate| std::ptr::eq(candidate, title)) }; // Indices into what was arranged, made indices into the library. let entries: Vec<(usize, usize)> = tiles .iter() .filter_map(|tile| Some((index_of(titles[tile.lead])?, tile.members.len()))) .collect(); self.folded = entries .iter() .filter(|(_, members)| *members > 1) .map(|(lead, members)| (*lead, members - 1)) .collect(); self.matches = entries.into_iter().map(|(lead, _)| lead).collect(); self.built_with = Some(built_with); if self.stale || self.progressed { self.history = ui::history::entries(library); self.resumable = Self::resumable(library); self.badges = Self::badges(library, &inputs); } if self.query != inputs.query || self.stale { self.found = pstr_core::discover::find(library, metadata, inputs.guides, inputs.query); } self.query = inputs.query.to_owned(); self.stale = false; self.progressed = false; } fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap { pstr_core::news::badges(&library.titles, inputs.first_seen, inputs.now) .into_iter() .map(|(key, badge)| (key, badge.label())) .collect() } /// The "Continue watching" shelf, as indices into the library. fn resumable(library: &Library) -> Vec { library .continue_watching() .into_iter() .filter_map(|title| { library .titles .iter() .position(|candidate| std::ptr::eq(candidate, title)) }) .collect() } } /// 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, /// Put the caret in the link field on the next frame the form is drawn. pub focus: bool, /// The share whose link is being re-entered in its own row, if any. pub repairing: Option, pub repair_url: String, pub repair_password: String, /// The share being renamed in its own row, if any, and the name so far. pub renaming: Option, pub rename_to: String, } pub struct App { engine: Engine, events: Receiver, /// What later launches asked for. See [`crate::instance`]. requests: Receiver, pub page: Page, pub library: Library, pub shares: Vec, /// What the last crawl of each share came to, for its row. pub crawls: HashMap, /// When, on egui's clock, to next look for shares due a crawl. next_stale_check: f64, /// 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, /// When each title last gained a file, by key — for "Recently added". pub added: HashMap, /// When each file first appeared in a crawl, for the New badges. first_seen: HashMap<(String, String), i64>, my_list: MyList, /// 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, 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, /// The Proton account section of the shares page. pub account: ui::account::AccountPanel, 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), /// The subtitle style changed and has not been written yet. subtitle_style_unsaved: bool, /// Which panel is out beside the picture on the player page. pub player_panel: Option, /// The sleep timer, when one is set. sleep: Option, /// 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)>, /// The page last drawn and when it was first drawn, for the fade in. shown_page: (Option, f64), /// Holds the screensaver off while a film is on screen and playing. inhibitor: crate::inhibit::Inhibitor, notifier: crate::notify::Notifier, pub downloads: Vec, pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, /// Finished offline copies the viewer asked to delete, and what they are /// of, waiting on their say-so: gigabytes are one misclick away otherwise. pub confirm_offline_delete: Option<(Vec, String)>, /// Metadata settings that would forget every stored answer, waiting on a /// yes. pub confirm_metadata: Option, /// Several episodes asked for offline at once, waiting on a yes, with how /// many are not offline yet and their size. pub confirm_download: Option<(Vec, usize, i64)>, /// 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, instance: crate::instance::Listener, request: crate::instance::Request, ) -> anyhow::Result { let requests = instance.serve(&runtime, &cc.egui_ctx, request); // 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 notifier = crate::notify::Notifier::new(&dirs.cache); let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; let my_list = engine.my_list(); theme::install_fonts(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); cc.egui_ctx.set_zoom_factor(engine.desktop_prefs().zoom()); // 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.forget_orphaned_shares(); engine.load_metadata(); engine.load_crawls(); engine.connect(); engine.crawl_stale(); let settings = engine.metadata_config(); let arranged = engine.desktop_prefs(); Ok(Self { engine, events, requests, page: Page::Library, library: Library::default(), shares: Vec::new(), crawls: HashMap::new(), next_stale_check: STALE_CHECK_EVERY, thumbs: ImageCache::default(), posters: ImageCache::default(), metadata: HashMap::new(), added: HashMap::new(), first_seen: HashMap::new(), my_list, 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::arranged(arranged), loaded: false, form: ShareForm::default(), account: ui::account::AccountPanel::default(), playback: None, overlay: ui::player::Overlay::default(), up_next: UpNext::default(), warmed: 0, auto_skipped: (0, Vec::new()), subtitle_style_unsaved: false, fullscreen: false, player_panel: None, sleep: None, 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(), shown_page: (None, 0.0), notifier, downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, confirm_offline_delete: None, confirm_metadata: None, confirm_download: 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, icon: &'static str, text: impl Into) { self.flash_level(ctx, icon, text, None); } /// The same, with a level drawn under it. fn flash_level( &mut self, ctx: &egui::Context, icon: &'static str, text: impl Into, level: Option, ) { if self.page == Page::Player { self.osd = Some(ui::player::Flash { icon, text: text.into(), level, seek: None, at: ctx.input(|input| input.time), }); } } /// Keep how the library is arranged for the next launch: a viewer who /// sorts by recently added does so every time, not once. fn remember_arrangement(&self) { let mut prefs = self.engine.desktop_prefs(); let arranged = (self.view.filter, self.view.sort, self.view.grouped); if (prefs.filter, prefs.sort, prefs.grouped) != arranged { (prefs.filter, prefs.sort, prefs.grouped) = arranged; self.engine.set_desktop_prefs(prefs); } } /// Say that the playing file failed, with a button that opens it again /// where it stopped — a dropped connection is the usual cause, and the /// session refresh behind the next open is usually the cure. fn offer_retry(&mut self, ctx: &egui::Context) { let Some(playback) = &self.playback else { return; }; let mut retry = playback.target.clone(); let at = if playback.position > 0.0 { playback.position } else { retry.resume_at.unwrap_or(0.0) }; retry.resume_at = (at > 0.0).then_some(at); let text = format!( "{} stopped playing at {}", retry.title_name, crate::ui::format_time(at) ); let now = ctx.input(|input| input.time); self.toasts .push_error_with(now, text, "Retry", Action::Play(retry)); } 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); // The first share is the library: watch it fill rather than // stay on a form that has done its job. A second one is // more likely one of several being added in a row. if self.library.is_empty() && self.page == Page::Shares { self.page = Page::Library; } } Event::ShareRejected { error, needs_password, } => { // Nothing was crawled, so nothing will say it finished. self.crawling = false; self.form.sending = false; self.form.error = Some(error); // The field the refusal asks for, opened and ready. if needs_password { self.form.has_password = true; } } 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::FirstSeen(first_seen) => self.first_seen = first_seen, Event::MyList(list) => { self.my_list = list; self.view.stale = true; } Event::LibraryLoaded(library, added) => { self.library = library; self.added = added; self.loaded = true; self.view.stale = true; } Event::Crawled { share_id, nodes, files, added, seconds, quiet, } => { let name = self.share_name(&share_id); self.crawl_progress.remove(&name); if quiet { // Looked at by the app, not the viewer: only news. if added > 0 { let text = format!( "{name}: {}", ui::library::plural(added as usize, "new file") ); self.note(ctx, &text, false); self.notify(ctx, &format!("New in {name}"), &text); } } else { self.note( ctx, format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), false, ); // Only a crawl long enough to have walked away from. if seconds >= 30.0 { self.notify(ctx, &format!("Crawled {name}"), &format!("{files} files")); } } } Event::Crawls(crawls) => self.crawls = crawls, Event::CrawlFinished => { self.crawling = false; self.crawl_progress.clear(); } Event::CrawlProgress { share, found } => { self.crawling = true; self.crawl_progress.insert(share, found); } Event::Downloaded(name) => { self.note(ctx, format!("{name} is available offline"), false); // Once per batch, when the last of it lands: a season is // two dozen files, and two dozen notifications is spam. let more = self.downloads.iter().any(|item| { matches!( item.state, crate::engine::DownloadState::Queued | crate::engine::DownloadState::Running ) }); if !more { self.notify(ctx, "Downloads finished", &name); } } 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; // Names, franchises and shelves all come from it. self.view.stale = true; } Event::EpisodeMetadata(episodes) => self.episodes = episodes, Event::Appearance(appearance) => { theme::apply(ctx, appearance); self.view.stale = true; } 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::MatchStarted => self.matching = true, 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::SkipTimes { player, ranges } => { if let Some(playback) = self.playback.as_mut().filter(|p| p.id == player) { playback.set_skip_ranges(ranges); } } Event::PlaybackReady { target, stream } => { let opening = self.opening.take(); // 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 target_key = target.title_key.clone(); 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) => { if self.page == Page::Player { self.page = match opening.map(|opening| opening.from) { Some(Page::Player) | None => Page::Title(target_key), Some(from) => from, }; } 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); } // mpv gave up partway. The page is about to go back to the // title, and without this the film would simply vanish. if mine && matches!( event, pstr_player::PlayerEvent::EndFile(pstr_player::EndReason::Failed) ) { self.offer_retry(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() { if self.fullscreen { self.fullscreen = false; ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); } self.page = match target { Some(key) => Page::Title(key), None => Page::Library, }; } // Where it stopped is the position another device // wants next, so it goes now rather than on the timer. self.engine.sync_watch_history(); } } Event::Watched { share_id, link_id, state, } => { if self.library.set_watch(&share_id, &link_id, state) { self.view.progressed = true; } } Event::Error(text) => { // The open failed: back to where it was asked from, with // the reason, rather than a player page with nothing on it. if let Some(opening) = self.opening.take() && self.page == Page::Player && self.playback.is_none() { self.page = match opening.from { Page::Player => Page::Title(opening.title_key), from => from, }; } 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) => { // The Downloaded filter is drawn from these. self.view.stale |= self.view.filter == Filter::Downloaded; self.offline_files = files; } Event::Account(status) => { if let AccountStatus::SignedIn(username) = &status && self.account.busy { self.note(ctx, format!("signed in as {username}"), false); } self.account.set_status(status); } Event::SignInFailed(error) => self.account.refused(error), Event::Synced(report) => { self.account.sync_error = None; self.account.synced = Some((ui::account::unix_now(), report)); } Event::SyncFailed(error) => self.account.sync_error = Some(error), Event::Places(places) => self.account.browser.places_arrived(places), Event::Folder { uid, entries } => self.account.browser.folder_arrived(uid, entries), Event::BrowseFailed(error) => self.account.browser.failed(error), } } // 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 } /// A desktop notification, if the window is not what the viewer is /// looking at. When it is, the toast already said it. fn notify(&self, ctx: &egui::Context, summary: &str, body: &str) { let focused = ctx.input(|input| input.viewport().focused.unwrap_or(true)); if !focused { self.notifier.send(summary, body); } } 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 && !self.stops_after_this(); 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() }; } // A file with no chapters has no credits to count over, so the card // takes its last seconds instead: without it the next episode cut in // with no warning and no way to stay. Not on a clip shorter than a // few countdowns, where the card would cover most of it. let tail = !playback.knows_credits() && playback .duration .is_some_and(|duration| duration > UP_NEXT_SECONDS * 6.0) && playback .remaining() .is_some_and(|left| left <= UP_NEXT_SECONDS); let counting = playback.loaded && !playback.paused && (playback.in_credits() || tail) && has_next && autoplay && !self.up_next.dismissed; if !counting { self.up_next.started = None; return None; } self.up_next.tail = tail; let now = ctx.input(|input| input.time); let started = *self.up_next.started.get_or_insert(now); let left = if tail { // Counted on the file's own clock, so it reaches zero at the end // of file, which is what starts the next one. playback.remaining().unwrap_or_default() } else { UP_NEXT_SECONDS - (now - started) }; if tail && left <= 0.0 { return None; } 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, names: self.engine.appearance().names, } .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, tail, }) } /// Whether the sleep timer is set to stop at the end of what is playing. fn stops_after_this(&self) -> bool { matches!( (self.sleep, &self.playback), (Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id == id ) } /// Pause once the sleep timer runs out. A timer for the end of an episode /// that is no longer playing — the viewer moved on to another — is /// dropped: it was about that episode. fn tick_sleep(&mut self, ctx: &egui::Context) { match (self.sleep, &self.playback) { (Some(Sleep::At(at)), Some(playback)) if std::time::Instant::now() >= at => { if !playback.paused { playback.send(crate::playback::Command::TogglePause); } self.sleep = None; self.note(ctx, "Sleep timer: paused", false); } (Some(Sleep::EndOfEpisode(id)), Some(playback)) if playback.id != id => { self.sleep = None; } (Some(_), None) => self.sleep = None, _ => {} } } /// Once a file has loaded with no chapters naming its opening or ending, /// ask AniSkip, when the episode's provider entry has a MyAnimeList id. fn ask_skip_times(&mut self) { let Some(playback) = &mut self.playback else { return; }; let Some(length) = playback.wants_skip_times() else { return; }; playback.skips_asked = true; let target = &playback.target; let found = self.library.get(&target.title_key).and_then(|title| { let episode = title.episodes().find(|episode| { episode.node.share_id == target.share_id && episode.node.link_id == target.link_id })?; let number = episode.node.parsed.episode?; self.episodes .get(&title.key)? .get(episode.node.parsed.season, number) }); if let Some((mal_id, number)) = found.and_then(|found| Some((found.mal_id?, found.entry_number?))) { self.engine .request_skip_times(playback.id, mal_id, number, length); } } /// 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.stops_after_this() { // The timer has done its job. self.sleep = None; return; } if !self.engine.playback_prefs().autoplay_next { return; } // Cancelled from the end-of-file card: the viewer chose to stay. let cancelled = self.up_next.tail && self.up_next.dismissed && self .playback .as_ref() .is_some_and(|playback| playback.id == self.up_next.playback_id); if cancelled { return; } let Some(target) = self.adjacent(Adjacent::Next) else { return; }; self.note(ctx, format!("next: {}", target.caption()), false); self.apply(ctx, Action::Play(target)); } /// Write the subtitle style, once whatever is changing it has let go. /// /// A slider or a colour wheel changes the style every frame it is dragged, /// and each change is shown on the playing file straight away. Writing /// each one would be a file write per frame, so it waits for the mouse — /// which also covers a menu closed mid-drag, where the control that would /// have said "released" is no longer drawn. fn save_subtitle_style(&mut self, ctx: &egui::Context) { if self.subtitle_style_unsaved && !ctx.input(|input| input.pointer.any_down()) { self.subtitle_style_unsaved = false; self.engine .set_playback_prefs(self.engine.playback_prefs(), true); } } 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); } /// Reset one file to never played, with an Undo. What leaving Continue /// watching and leaving the history both come down to: the two are read /// off the same watch state. fn forget_watch( &mut self, ctx: &egui::Context, share_id: String, link_id: String, place: &str, ) { let found = self.library.titles.iter().find_map(|title| { let episode = title.episodes().find(|episode| { episode.node.share_id == share_id && episode.node.link_id == link_id })?; Some((title.name.clone(), episode.watch)) }); let Some((name, before)) = found else { return; }; // An episode Continue watching offers next has never been played. // Its undo is a reset as old as time, which the finished episode // before it outranks. let before = before.unwrap_or(pstr_core::catalog::WatchState { position_secs: 0.0, duration_secs: None, watched: false, updated_at: 0, }); self.engine.save_watch_state( share_id.clone(), link_id.clone(), watch_state(0.0, before.duration_secs, false), ); let now = ctx.input(|input| input.time); self.toasts.push_with( now, format!("removed {name} from {place}"), "Undo", Action::RestoreWatch(vec![(share_id, link_id, before)]), ); } fn apply(&mut self, ctx: &egui::Context, action: Action) { // A search that led somewhere is one worth offering again. if self.page == Page::Library && matches!( action, Action::Goto(Page::Title(_) | Page::Browse { .. }) | Action::Play(_) ) { self.engine.remember_search(Some(&self.search)); } 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); // To the player page now, not when the file has opened: over a // public link that can take seconds, and a click that seems to // do nothing gets clicked again. self.opening = Some(Opening { name: target.name.clone(), title_key: target.title_key.clone(), from: self.page.clone(), }); self.page = Page::Player; self.engine.play(target); } Action::MakeOffline(targets) => { // A season or a show is asked about, with its size: forty // episodes is tens of gigabytes, and one click should not // fill a disk. let missing: Vec<_> = targets .iter() .filter(|target| !self.offline_files.contains(&DownloadKey::from(*target))) .collect(); if missing.len() > 1 { let bytes = missing .iter() .filter_map(|target| { self.library .get(&target.title_key)? .episodes() .find(|episode| { episode.node.share_id == target.share_id && episode.node.link_id == target.link_id })? .node .size }) .sum(); let count = missing.len(); self.confirm_download = Some((targets, count, bytes)); } else { 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::DeleteOffline(keys, what) => { if !keys.is_empty() { self.confirm_offline_delete = Some((keys, what)); } } Action::SetMetadataConfig(config) => { // Turning it off or changing provider forgets every answer, // hand-picked matches included: asked, when there are some. let previous = self.engine.metadata_config(); let forgets = previous.enabled && (!config.enabled || config.provider != previous.provider); if forgets && !self.metadata.is_empty() { self.settings = previous; self.confirm_metadata = Some(config); } else { 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(_) | Page::Browse { .. } ) { 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; self.remember_arrangement(); } Action::SetGrouped(grouped) => { self.view.grouped = grouped; self.remember_arrangement(); } Action::SetOnList { key, on } => { self.my_list = self.engine.set_on_list(&key, on); self.view.stale = true; } Action::Back => match &self.page { Page::Title(_) => self.page = Page::Library, Page::Browse { from, .. } => { self.page = from.clone().map_or(Page::Library, Page::Title) } // 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, &self.opening) { (Some(playback), _) => Page::Title(playback.target.title_key.clone()), (None, Some(opening)) => Page::Title(opening.title_key.clone()), (None, 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::Screenshot => { let Some(playback) = &self.playback else { return; }; let Some(folder) = screenshot_folder() else { return self.note(ctx, "no pictures folder to save a screenshot in", true); }; if let Err(error) = std::fs::create_dir_all(&folder) { return self.note(ctx, format!("could not save a screenshot: {error}"), true); } let path = folder.join(screenshot_name( &playback.target.title_name, playback.target.caption().as_str(), playback.position, )); self.send_player(crate::playback::Command::Screenshot(path)); self.flash(ctx, egui_phosphor::regular::CAMERA, "Screenshot saved"); } Action::PasteShare(url) => { self.page = Page::Shares; self.form.url = url; self.form.error = None; self.note(ctx, "share link pasted — add it, or name it first", false); } Action::RenameShare { id, name } => { self.form.renaming = None; self.engine.rename_share(id, name); } Action::RepairShare { id, url, password } => { self.crawling = true; self.form.repairing = None; self.form.repair_url.clear(); self.form.repair_password.clear(); self.engine.repair_share(id, url, password); } Action::PlayResuming(mut target) => { target.resume_at = self.library.titles.iter().find_map(|title| { title .episodes() .find(|episode| { episode.node.share_id == target.share_id && episode.node.link_id == target.link_id }) .and_then(|episode| episode.resume_at()) }); self.apply(ctx, Action::Play(target)); } Action::OpenShareForm => { self.page = Page::Shares; self.form.focus = true; } Action::DismissMetadataOffer => { let mut prefs = self.engine.desktop_prefs(); prefs.metadata_offer_dismissed = true; self.engine.set_desktop_prefs(prefs); } Action::AddShare { name, url, password, } => { self.crawling = true; self.form.sending = true; self.form.error = None; self.engine.add_share(name, url, password); } Action::AddAccountFolder { name, uid } => { self.crawling = true; self.engine.add_account_folder(name, uid); } Action::SignIn { username, password } => self.engine.sign_in(username, password), Action::SubmitSecondFactor(code) => self.engine.submit_second_factor(code), Action::SubmitMailboxPassword(password) => { self.engine.submit_mailbox_password(password) } Action::CancelSignIn => self.engine.cancel_sign_in(), Action::SignOut => self.engine.sign_out(), Action::SyncNow => self.engine.sync_watch_history(), Action::BrowsePlaces => self.engine.browse_places(), Action::BrowseFolder(uid) => self.engine.browse_folder(uid), 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.page == Page::Player { let now = ctx.input(|input| input.time); self.osd = Some(ui::player::Flash::seek(self.osd.as_ref(), seconds, now)); } if let Some(playback) = &self.playback { playback.send(command); } } 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, egui_phosphor::regular::GAUGE, text); } else { self.note(ctx, text, false); } } Action::SetDesktopPrefs(prefs) => { ctx.set_zoom_factor(prefs.zoom()); self.engine.set_desktop_prefs(prefs); } Action::SetTitleWatched { key, watched } => { let Some(title) = self.library.get(&key) else { return; }; let links = title .episodes() .map(|e| (e.node.share_id.clone(), e.node.link_id.clone())) .collect(); let what = title.name.clone(); self.apply( ctx, Action::MarkEpisodes { key, links, watched, what, }, ); } Action::MarkEpisodes { key, links, watched, what: name, } => { let Some(title) = self.library.get(&key) else { return; }; let picked = |e: &&pstr_core::library::Episode| { links .iter() .any(|(share, link)| e.node.share_id == *share && e.node.link_id == *link) }; let mut before = Vec::new(); for episode in title .episodes() .filter(picked) .filter(|e| e.is_watched() != watched) { let duration = episode.watch.and_then(|watch| watch.duration_secs); before.push(( episode.node.share_id.clone(), episode.node.link_id.clone(), episode.watch.unwrap_or(watch_state(0.0, duration, false)), )); self.engine.save_watch_state( episode.node.share_id.clone(), episode.node.link_id.clone(), watch_state( if watched { duration.unwrap_or(0.0) } else { 0.0 }, duration, watched, ), ); } let now = ctx.input(|input| input.time); self.toasts.push_with( now, format!( "marked {name} {}", if watched { "watched" } else { "unwatched" } ), "Undo", Action::RestoreWatch(before), ); } Action::ForgetPosition { share_id, link_id } => { self.forget_watch(ctx, share_id, link_id, "Continue watching"); } Action::RemoveFromHistory { share_id, link_id } => { self.forget_watch(ctx, share_id, link_id, "history"); } Action::SetSleep(choice) => { self.sleep = match choice { None => None, Some(ui::player::SleepChoice::EndOfEpisode) => self .playback .as_ref() .map(|playback| Sleep::EndOfEpisode(playback.id)), Some(ui::player::SleepChoice::Minutes(minutes)) => Some(Sleep::At( std::time::Instant::now() + std::time::Duration::from_secs(u64::from(minutes) * 60), )), }; let text = match choice { None => "Sleep timer off".to_owned(), Some(ui::player::SleepChoice::EndOfEpisode) => { "Stopping after this episode".to_owned() } Some(ui::player::SleepChoice::Minutes(minutes)) => { format!("Pausing in {minutes} min") } }; self.flash(ctx, egui_phosphor::regular::MOON, text); } Action::TogglePanel(panel) => { self.player_panel = (self.player_panel != Some(panel)).then_some(panel); } Action::RestoreWatch(states) => { for (share_id, link_id, state) in states { self.engine.save_watch_state(share_id, link_id, state); } } Action::ClearStreamCache => self.engine.clear_stream_cache(), 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::SetLanguages { audio, subtitles, subtitle, } => { let mut prefs = self.engine.playback_prefs(); prefs.audio_language = audio; prefs.subtitles = subtitles; prefs.subtitle_language = subtitle; self.engine.set_playback_prefs(prefs, true); } Action::SetSubtitleStyle(style) => { let mut prefs = self.engine.playback_prefs(); prefs.subtitle_style = style; self.engine.set_playback_prefs(prefs, false); self.subtitle_style_unsaved = true; let style = self.engine.playback_prefs().subtitle_style; if let Some(playback) = &mut self.playback { playback.restyle_subtitles(&style); } } Action::NudgeSubtitleDelay(delta) => { if let Some(playback) = &mut self.playback { playback.set_subtitle_delay(playback.subtitle_delay + delta); let text = format!( "Subtitle delay {}", ui::format_delay(playback.subtitle_delay) ); self.flash(ctx, egui_phosphor::regular::SUBTITLES, text); } } Action::ResetSubtitleDelay => { if let Some(playback) = &mut self.playback { playback.set_subtitle_delay(0.0); self.flash(ctx, egui_phosphor::regular::SUBTITLES, "Subtitle delay 0 s"); } } 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_level( ctx, if volume <= 0.0 { egui_phosphor::regular::SPEAKER_SLASH } else if volume < 50.0 { egui_phosphor::regular::SPEAKER_LOW } else { egui_phosphor::regular::SPEAKER_HIGH }, format!("Volume {volume:.0}%"), Some((volume / pstr_player::MAX_VOLUME) as f32), ); } } Action::ToggleMute => { 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)); if prefs.muted { self.flash(ctx, egui_phosphor::regular::SPEAKER_SLASH, "Muted"); } else { self.flash(ctx, egui_phosphor::regular::SPEAKER_HIGH, "Sound on"); } self.engine.set_playback_prefs(prefs, true); } Action::SelectTrack { kind, id } => { 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(theme::space::XS); ui.label( theme::Role::Heading .rich("proton-stream") .strong() .color(theme::accent()), ); ui.add_space(theme::space::L); let on_library = matches!( self.page, Page::Library | Page::Title(_) | Page::Browse { .. } ); 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 = NAV; let clicked = ui::tabs( ui, ui.id().with("nav"), &[ ("Library", on_library), ("History", self.page == Page::History), ("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(theme::space::XS); 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(format!( "{} Refresh", egui_phosphor::regular::ARROW_CLOCKWISE )) .on_hover_text("Re-crawl every share") .clicked() { actions.push(Action::Crawl(None)); } if on_library && !self.library.is_empty() { ui.add_space(theme::space::M); if ui::search_field(ui, search, focus_search, &self.engine.recent_searches()) { self.engine.remember_search(None); } } }); }); } } 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); // Ctrl+plus and Ctrl+minus zoom too, through egui: keep what they // settled on, so Settings shows it and the next launch opens at it. let mut desktop = self.engine.desktop_prefs(); if (ctx.zoom_factor() - desktop.zoom()).abs() > 0.005 { desktop.text_size = ((ctx.zoom_factor() * 100.0).round() as u16).clamp(50, 200); ctx.set_zoom_factor(desktop.zoom()); self.engine.set_desktop_prefs(desktop); } // The window manager can leave fullscreen without asking (F11, a // double-click on a title bar), and `F` then toggled the wrong way. if let Some(actual) = ctx.input(|input| input.viewport().fullscreen) { self.fullscreen = actual; } // A window left open for days still picks up what was added: the // launch check alone would only ever run once. let now = ctx.input(|input| input.time); if now >= self.next_stale_check { self.next_stale_check = now + STALE_CHECK_EVERY; if now > 0.0 && !self.crawling { self.engine.crawl_stale(); } } self.warm_next(); self.ask_skip_times(); self.tick_sleep(ctx); self.auto_skip(ctx); self.save_subtitle_style(ctx); self.window.observe(ctx); let mut actions: Vec = Vec::new(); while let Ok(request) = self.requests.try_recv() { // A second launch is someone looking for this window, so it comes // forward even when there is nothing else to do. ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if let crate::instance::Request::AddShare(url) = request { actions.push(Action::PasteShare(url)); } } 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, self.player_panel, state, &mut actions); // Before the draw, because drawing does not mutate — and it can // push an action of its own, which is why it takes the same list. let up_next = self.tick_up_next(ctx, &mut actions); let neighbours = self.neighbours(); let App { playback, opening, overlay, engine, library, thumbs, posters, metadata, episodes, player_panel, sleep, .. } = self; // Before the list borrows the art caches for the rest of the frame. let waiting = opening .as_ref() .filter(|_| playback.is_none()) .map(|opening| { let title = library.get(&opening.title_key); let mut art = ui::Art { engine, thumbs, posters, metadata, episodes, names: engine.appearance().names, }; ui::player::Waiting { name: &opening.name, title: title.map(|title| art.name(title).to_owned()), art: title.and_then(|title| art.wide(title)).map(|(art, _)| art), next: opening.from == Page::Player, } }); // Only while paused, which is the only time the pause screen // can show: the logo is a texture lookup, and could be a fetch. let about = playback .as_ref() .filter(|playback| playback.paused) .and_then(|playback| { let title = library.get(&playback.target.title_key)?; let mut art = ui::Art { engine, thumbs, posters, metadata, episodes, names: engine.appearance().names, }; let target = &playback.target; let episode = title .seasons .iter() .flat_map(|season| &season.episodes) .find(|episode| { episode.node.share_id == target.share_id && episode.node.link_id == target.link_id }); let synopsis = episode .and_then(|episode| art.episode(&title.key, episode)) .and_then(|found| found.overview.clone()) .or_else(|| { art.metadata .get(&title.key) .and_then(|record| record.metadata.as_ref()) .and_then(|found| found.overview.clone()) }); Some(ui::player::About { name: art.name(title).to_owned(), logo: art.logo(title), synopsis, }) }); let list = playback .as_ref() .and_then(|playback| library.get(&playback.target.title_key)) .filter(|title| title.episode_count() > 1) .map(|title| ui::player::Episodes { title, art: ui::Art { engine, thumbs, posters, metadata, episodes, names: engine.appearance().names, }, }); // 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(), waiting, ui::player::Chrome { overlay, up_next, neighbours, episodes: list, panel: *player_panel, about, sleep: sleep.map(Sleep::choice), }, &mut actions, ); }); if let Some(flash) = &self.osd && !flash.show(ctx, ctx.content_rect()) { self.osd = None; } if let Some(action) = self.toasts.show(ctx, self.overlay.covered()) { actions.push(action); } 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_offline_delete.is_some() || self.confirm_metadata.is_some() || self.confirm_download.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, playback: &playback_prefs, appearance: self.engine.appearance(), desktop: self.engine.desktop_prefs(), cache_used: (self.page == Page::Settings) .then(|| self.engine.cache_used()) .flatten(), }; 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, ViewInputs { query: &self.search, metadata: &self.metadata, names: self.engine.appearance().names, added: &self.added, offline: &self.offline_files, list: &self.my_list, first_seen: &self.first_seen, guides: &self.episodes, now: crate::engine::unix_now(), }, ); // After the refresh, which forgets it when the library changed. if let Page::Title(key) = &self.page && self.view.similar.as_ref().is_none_or(|(of, _)| of != key) { let keys = pstr_core::discover::more_like( key, &self.library.titles, &self.metadata, SIMILAR, ); self.view.similar = Some((key.clone(), keys)); } let crawl = self.crawling.then(|| crawl_label(&self.crawl_progress)); let App { engine, page, library, shares, crawls, thumbs, posters, metadata, episodes, settings, api_key, search, view, loaded, form, account, matcher, playback, opening, downloads, offline_files, confirm_partial_delete, confirm_offline_delete, confirm_metadata, confirm_download, confirm_remove_share, shown_page, .. } = 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, frame, playback.as_mut(), opening.as_ref().map(|opening| opening.name.as_str()), neighbours, &mut actions, ); }) .response .rect .height(); } let fade = page_fade(ctx, shown_page, page); egui::CentralPanel::default() .frame( egui::Frame::new() .fill(theme::background()) .inner_margin(egui::Margin { left: ui::PAGE_MARGIN.x as i8, right: ui::PAGE_MARGIN.x as i8, // The library's banner starts right under the bar; // the page keeps the margin itself when it has no // banner to show. top: if *page == Page::Library { 0 } else { ui::PAGE_MARGIN.y as i8 }, bottom: ui::PAGE_MARGIN.y as i8, }), ) .show(ui, |ui| { ui.multiply_opacity(fade); // 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, names: engine.appearance().names, }; match page { Page::Library => ui::library::show( ui, &mut art, ui::library::Shelves { library, view, loaded: *loaded, search, tiles: engine.appearance().tiles, crawl: crawl.as_deref(), offer_metadata: !settings.enabled && !engine.desktop_prefs().metadata_offer_dismissed, }, &mut actions, ), Page::Title(key) => ui::title::show( ui, &mut art, library, key, ui::title::Context { franchise: view.franchises.get(key.as_str()).map(Vec::as_slice), similar: view .similar .as_ref() .filter(|(of, _)| of == key) .map(|(_, keys)| keys.as_slice()) .unwrap_or_default(), on_list: view.listed.contains(key.as_str()), }, ui::title::OfflineView { downloads, files: offline_files, }, &mut actions, ), Page::Browse { facet, from } => ui::library::browse( ui, &mut art, library, ui::library::Browsing { facet, from: from.as_deref(), }, view, engine.appearance().tiles, &mut actions, ), Page::Shares => ui::shares::show( ui, shares, crawls, library, form, account, &mut actions, ), Page::Settings => { ui::settings::show(ui, settings, prefs, api_key, &mut actions) } Page::Downloads => ui::downloads::show(ui, downloads, &mut actions), Page::History => { ui::history::show(ui, &mut art, library, &view.history, &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, names: engine.appearance().names, }; 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((keys, what)) = confirm_offline_delete.clone() { let body = format!( "{} deleted from this computer. The share keeps the files, and they can \ be downloaded again.", match keys.len() { 1 => "The episode is".to_owned(), count => format!("All {count} episodes are"), } ); match ui::confirm( ctx, "offline", &format!("Delete the offline copy of {what}?"), &body, "Delete", ) { ui::Answer::Confirmed => { for key in keys { engine.remove_download(key, false); } *confirm_offline_delete = None; } ui::Answer::Declined => *confirm_offline_delete = None, ui::Answer::Pending => {} } } if let Some((targets, count, bytes)) = confirm_download.clone() { let what = targets .first() .map_or("these episodes", |target| target.title_name.as_str()); let size = if bytes > 0 { format!(" — about {}", ui::format_size(bytes)) } else { String::new() }; match ui::confirm_with( ctx, "download", &format!("Download {count} episodes of {what}?"), &format!( "They are kept on this computer for watching without a connection{size}. \ Delete them from the Downloads page." ), "Download", false, ) { ui::Answer::Confirmed => { engine.make_offline(targets); *confirm_download = None; } ui::Answer::Declined => *confirm_download = None, ui::Answer::Pending => {} } } if let Some(config) = confirm_metadata.clone() { let picked = metadata.values().filter(|record| record.manual).count(); let (heading, verb) = if config.enabled { (format!("Switch to {}?", config.provider.label()), "Switch") } else { ("Turn off posters and descriptions?".to_owned(), "Turn off") }; let mut body = "Every poster, description and episode name stored now is \ forgotten" .to_owned(); if picked > 0 { body.push_str(&format!( ", and so {} you chose by hand", match picked { 1 => "is the 1 match".to_owned(), count => format!("are the {count} matches"), } )); } body.push_str(if config.enabled { ". The library is looked up again with the new provider." } else { ". Nothing more is sent anywhere." }); match ui::confirm(ctx, "metadata", &heading, &body, verb) { ui::Answer::Confirmed => { *settings = config.clone(); engine.set_metadata_config(config); *confirm_metadata = None; } ui::Answer::Declined => *confirm_metadata = 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); } if let Some(action) = self.toasts.show(ctx, covered) { actions.push(action); } } 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)); // After the player: its last position is saved on the way out. self.engine .sync_before_exit(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 } /// Where screenshots go: a folder of their own in the pictures folder. fn screenshot_folder() -> Option { let user = directories::UserDirs::new()?; let pictures = user .picture_dir() .map(std::path::Path::to_path_buf) .unwrap_or_else(|| user.home_dir().to_path_buf()); Some(pictures.join("proton-stream")) } /// `Frieren S01E04 · The Land Where Souls Rest 12-34.png` — what it is /// and where in it, with anything a file system would refuse taken out. fn screenshot_name(title: &str, caption: &str, position: f64) -> String { let at = ui::format_time(position).replace(':', "-"); let name = if caption.is_empty() { format!("{title} {at}") } else { format!("{title} {caption} {at}") }; let safe: String = name .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', c if c.is_control() => ' ', c => c, }) .collect(); format!("{}.png", safe.trim()) } /// How far the page has faded in. A new page arrives over a few frames /// rather than in one: the cut between two pages of tiles reads as a flicker, /// a short fade as a change of place. fn page_fade(ctx: &egui::Context, shown: &mut (Option, f64), page: &Page) -> f32 { const SECONDS: f64 = theme::motion::STATE as f64; let now = ctx.input(|input| input.time); if shown.0.as_ref() != Some(page) { // The first page of a launch is there at once; only changes fade. let first = shown.0.is_none(); *shown = (Some(page.clone()), if first { now - SECONDS } else { now }); } let progress = ((now - shown.1) / SECONDS).clamp(0.0, 1.0) as f32; if progress < 1.0 { ctx.request_repaint(); } theme::motion::ease_out(progress) } /// 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") } } /// Texture keys whose picture is not what it was: a title's tile art, its cover /// and its wide art each. /// /// 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 { // Every picture a title is drawn with, in the order of the keys below. let urls = |records: &HashMap, key: &str| { let found = records.get(key).and_then(|record| record.metadata.as_ref()); [ found .and_then(|metadata| metadata.tile_art()) .map(|(url, _)| url.to_owned()), found.and_then(|metadata| metadata.poster_url.clone()), found.and_then(|metadata| metadata.wide_art().map(str::to_owned)), found.and_then(|metadata| metadata.logo_url.clone()), // The poster again: the blurred band is made from it. found.and_then(|metadata| metadata.poster_url.clone()), ] }; let mut keys: Vec = Vec::new(); for key in before.keys().chain(after.keys()) { let (old, new) = (urls(before, key), urls(after, key)); let texture_keys = [ key.clone(), ui::poster_key(key), ui::wide_key(key), ui::logo_key(key), ui::ambient_key(key), ]; for ((old, new), texture) in old.iter().zip(&new).zip(texture_keys) { if old != new { keys.push(texture); } } } 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}; // A text field, not any focused widget: a tile reached with Tab or the // arrow keys has focus too, and "/" over it should still search. let typing = ctx.text_edit_focused(); 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(_) | Page::Browse { .. }) && 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), (Key::Num5, 4), ] { if input.consume_key(Modifiers::COMMAND, key) { tab = Some(index); } } if input.consume_key(Modifiers::COMMAND, Key::Comma) { tab = Some(4); } // A share link pasted anywhere outside a text field is someone adding // a share, wherever they happen to be. if !typing { for event in &input.events { if let egui::Event::Paste(text) = event && pstr_core::shares::share_token(text.trim()).is_ok() { actions.push(Action::PasteShare(text.trim().to_owned())); } } } }); if let Some(index) = tab { let page = NAV[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, } /// A sleep timer that is running. #[derive(Debug, Clone, Copy)] enum Sleep { /// Stop when player `id`'s file ends, rather than going on to the next. EndOfEpisode(u64), /// Pause at this moment. At(std::time::Instant), } impl Sleep { /// What the panel shows as chosen. fn choice(self) -> ui::player::SleepChoice { match self { Self::EndOfEpisode(_) => ui::player::SleepChoice::EndOfEpisode, Self::At(at) => ui::player::SleepChoice::Minutes( (at.saturating_duration_since(std::time::Instant::now()) .as_secs() / 60) as u32 + 1, ), } } } /// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a /// tenth, because 1.0 has to be somewhere a viewer can land on again. use crate::ui::transport::SPEEDS; /// 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, panel: Option, 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; // Before the plain keys, and consumed: Ctrl+S is not also S, which skips. if ctx.input_mut(|input| input.consume_key(egui::Modifiers::COMMAND, Key::S)) { actions.push(Action::Screenshot); } 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::E, Key::Z, Key::X, 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. // mpv's own keys for it, and the step mpv uses. Key::Z => actions.push(Action::NudgeSubtitleDelay(-0.1)), Key::X => actions.push(Action::NudgeSubtitleDelay(0.1)), Key::E => actions.push(Action::TogglePanel(ui::player::PlayerPanel::Episodes)), // A panel is the innermost thing open, so it goes first. Key::Escape if panel.is_some() => { actions.push(Action::TogglePanel(panel.expect("checked above"))); } Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen), Key::Escape => actions.push(Action::LeavePlayer), _ => {} } } } /// The transport bar, or the line that says a file is still opening. fn transport_panel( ui: &mut egui::Ui, frame: &mut eframe::Frame, playback: Option<&mut Playback>, opening: Option<&str>, neighbours: ui::transport::Neighbours, actions: &mut Vec, ) { match playback { Some(playback) => { // The film, small. Drawn as often as the window is — four times a // second off the player page, see `Engine::wake` — which is enough // for a reminder and costs a thumbnail-sized render, not a frame. let scale = ui.ctx().pixels_per_point(); let size = ui::transport::MINI_PICTURE * scale; let picture = playback.video.as_mut().and_then(|video| { let texture = video.texture(frame, [size.x.round() as i32, size.y.round() as i32])?; video.has_picture().then_some(texture) }); ui::transport::mini(ui, playback, picture, 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, logo_url: None, rating: None, genres: Vec::new(), episodes: None, url: None, details: None, }); ( key.into(), MetadataRecord { title_key: key.into(), provider: ProviderId::AniList, metadata, fetched_at: 0, manual: false, }, ) } #[test] fn a_screenshot_name_says_what_and_when_without_a_slash() { assert_eq!( screenshot_name("AC/DC Live", "S01E02", 754.0), "AC-DC Live S01E02 12-34.png" ); assert_eq!(screenshot_name("Film", "", 5.0), "Film 0-05.png"); } #[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(); // The tile, the cover and the blurred band are all drawn from the // poster here; the wide art has nothing to be. assert_eq!( changed_art(&before, &after), vec![ "added", "ambient:added", "ambient:dropped", "ambient:found", "ambient:moved", "dropped", "found", "moved", "poster:added", "poster:dropped", "poster:found", "poster:moved" ] ); } }