Files
proton-stream/crates/pstr-app/src/app.rs
T
nvrl f66faf6b75 feat(core,app,android,ios): My List, synced, and New badges with new-episode rows
My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
2026-10-30 22:15:51 +01:00

3254 lines
132 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The window: state, the event pump, and what a click turns into.
//!
//! One rule holds this together — **drawing never mutates**. A page is handed
//! what it needs by reference and pushes [`Action`]s onto a list; the list is
//! applied after the frame. That is what lets a card click change the page it
//! is drawn on, and it is why none of the `ui` modules take an `&mut App`.
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use pstr_core::Share;
use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, 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::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),
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<PlaybackTarget>),
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<DownloadKey>, String),
/// Crawl one share, or every share.
Crawl(Option<String>),
/// 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<String>,
},
AddShare {
name: String,
url: String,
password: Option<String>,
},
/// 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<f64>,
},
Player(crate::playback::Command),
/// Change the volume, 0–100. `commit` writes it to the preferences; a
/// slider mid-drag does not.
SetVolume {
volume: f64,
commit: bool,
},
ToggleMute,
/// Play this track of that kind, or none of it, and remember the language
/// for the next file.
SelectTrack {
kind: pstr_player::TrackKind,
id: Option<i64>,
},
/// Turn enrichment on or off, or change provider.
SetMetadataConfig(MetadataConfig),
SetApiKey {
provider: pstr_core::metadata::ProviderId,
key: String,
},
/// Look every title up. `force` re-asks about ones already matched.
MatchTitles {
force: bool,
},
/// Open the hand-matching search for one title, seeded with its own name.
OpenMatcher(String),
CloseMatcher,
/// Ask the provider what the text in the box might be.
SearchMatches,
/// Pin the open title to this entry.
ChooseMatch(Box<pstr_core::metadata::TitleMetadata>),
/// Forget what is stored for a title, so it is matched from scratch again.
ForgetMatch(String),
/// Play the file before or after the one playing, within its title.
PlayAdjacent(Adjacent),
/// Start or stop the next episode playing on its own at the end of one.
SetAutoplay(bool),
/// 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<String>,
subtitles: bool,
subtitle: Option<String>,
},
/// 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 the list of episodes beside the picture.
ToggleEpisodes,
/// 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<f64>,
/// The viewer said to play this one out, or the countdown has already
/// fired. Either way, do not ask again about this file.
dismissed: bool,
/// 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<pstr_core::metadata::TitleMetadata>,
/// A request is out. The box stays usable; only the button is held.
pub searching: bool,
/// Whether a search has come back yet, so an empty list can read as "nothing
/// found" rather than "nothing asked".
pub asked: bool,
pub error: Option<String>,
/// Set for the frame the dialog opens, to put the caret in the box.
pub focus: bool,
}
impl Matcher {
pub fn new(title: &pstr_core::library::Title) -> Self {
Self {
title_key: title.key.clone(),
title_name: title.name.clone(),
kind: title.kind,
query: title.name.clone(),
results: Vec::new(),
searching: false,
asked: false,
error: None,
focus: true,
}
}
}
/// What the library page lists, worked out once per change rather than once a
/// frame.
///
/// A search lowercases every filename in the library, and the shelf sorts
/// every title by when it was last played. Neither is slow once; both were
/// being done on every frame, and while a film plays under the transport bar
/// that is the film's frame rate.
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<usize>,
/// In a grouped grid, how many other titles a tile stands for, by index.
pub folded: HashMap<usize, usize>,
/// 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<String, Vec<String>>,
/// The rows above the grid, as a heading and indices.
pub shelves: Vec<(String, Vec<usize>)>,
/// Every played episode, newest first, for the history page.
pub history: Vec<ui::history::Entry>,
/// The "Continue watching" shelf, most recent first, as indices.
pub resumable: Vec<usize>,
/// What each tile says about what arrived — `New`, `3 new` — by key.
pub badges: HashMap<String, String>,
/// The keys on My List.
pub listed: std::collections::HashSet<String>,
/// 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(),
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<String, MetadataRecord>,
pub names: TitleNames,
/// When each title last gained a file, by key.
pub added: &'a HashMap<String, i64>,
/// The files kept on this computer, for the Downloaded filter.
pub offline: &'a std::collections::HashSet<DownloadKey>,
pub list: &'a MyList,
/// When each file first appeared in a crawl, for what is new.
pub first_seen: &'a HashMap<(String, String), i64>,
pub now: i64,
}
/// How many shelves the library shows above the grid.
const SHELVES: usize = 4;
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 {
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<String> = 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.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);
}
self.query = inputs.query.to_owned();
self.stale = false;
self.progressed = false;
}
fn badges(library: &Library, inputs: &ViewInputs<'_>) -> HashMap<String, String> {
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<usize> {
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<String>,
/// 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<String>,
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<String>,
pub rename_to: String,
}
pub struct App {
engine: Engine,
events: Receiver<Event>,
/// What later launches asked for. See [`crate::instance`].
requests: Receiver<crate::instance::Request>,
pub page: Page,
pub library: Library,
pub shares: Vec<Share>,
/// What the last crawl of each share came to, for its row.
pub crawls: HashMap<String, pstr_core::catalog::CrawlRecord>,
/// 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<String, MetadataRecord>,
/// When each title last gained a file, by key — for "Recently added".
pub added: HashMap<String, i64>,
/// 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<String, EpisodeGuide>,
pub settings: MetadataConfig,
/// What the viewer is typing into the API key box. Never persisted here —
/// it goes straight to the credential store on save.
pub api_key: String,
pub matching: bool,
/// The hand-matching dialog, while it is open.
pub matcher: Option<Matcher>,
pub search: String,
/// Set for the frame the search box should take the caret.
focus_search: bool,
/// Whether the list of keyboard shortcuts is open.
pub shortcuts_open: bool,
/// What a key just did in the player, and when: "Volume 55%", "+30 s".
osd: Option<(String, f64)>,
pub view: LibraryView,
/// Whether the catalog has been read at all yet. Until it has, an empty
/// library means "not loaded", not "nothing in it".
pub loaded: bool,
pub form: ShareForm,
/// The Proton account section of the shares page.
pub account: ui::account::AccountPanel,
pub playback: Option<Playback>,
/// Whether the player page's controls are showing, and why.
pub overlay: ui::player::Overlay,
/// The end-of-episode countdown for whatever is playing.
pub up_next: UpNext,
/// The player whose next episode has already been fetched ahead, so it is
/// asked for once per file rather than once a frame.
warmed: u64,
/// Chapters already skipped on their own in this player, so seeking back
/// into an opening on purpose is not undone a frame later.
auto_skipped: (u64, Vec<i64>),
/// The subtitle style changed and has not been written yet.
subtitle_style_unsaved: bool,
/// Whether the episode list is out on the player page.
pub episode_drawer: bool,
/// 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<Opening>,
pub connecting: bool,
pub crawling: bool,
/// Which share the crawl is on, and how far it has got.
/// Files found so far, by share name, for each share still listing.
crawl_progress: std::collections::BTreeMap<String, usize>,
/// The window's size, written at exit for the next launch.
window: crate::window::WindowMemory,
/// Media keys and the desktop's media widget.
media: crate::media::MediaSession,
/// The cover handed to it, worked out once per player.
media_cover: Option<(u64, Option<String>)>,
/// The page last drawn and when it was first drawn, for the fade in.
shown_page: (Option<Page>, f64),
/// Holds the screensaver off while a film is on screen and playing.
inhibitor: crate::inhibit::Inhibitor,
notifier: crate::notify::Notifier,
pub downloads: Vec<DownloadItem>,
pub offline_files: std::collections::HashSet<DownloadKey>,
pub confirm_partial_delete: Option<DownloadKey>,
/// 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<DownloadKey>, String)>,
/// Metadata settings that would forget every stored answer, waiting on a
/// yes.
pub confirm_metadata: Option<MetadataConfig>,
/// 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<PlaybackTarget>, usize, i64)>,
/// A share the viewer asked to remove, waiting on their say-so.
pub confirm_remove_share: Option<String>,
/// Messages in the corner. See [`ui::toast`].
pub toasts: ui::toast::Toasts,
/// The frame ceiling, where this session is pacing itself rather than
/// letting vsync do it — see [`crate::pacing`].
pacer: Pacer,
}
impl App {
pub fn new(
cc: &eframe::CreationContext<'_>,
runtime: std::sync::Arc<tokio::runtime::Runtime>,
dirs: AppDirs,
instance: crate::instance::Listener,
request: crate::instance::Request,
) -> anyhow::Result<Self> {
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,
episode_drawer: false,
opening: None,
connecting: true,
crawling: false,
crawl_progress: Default::default(),
window,
media: crate::media::MediaSession::new(&cc.egui_ctx, window_handle(cc)),
media_cover: None,
inhibitor: crate::inhibit::Inhibitor::new(),
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, text: impl Into<String>) {
if self.page == Page::Player {
self.osd = Some((text.into(), 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<String>, error: bool) {
let now = ctx.input(|input| input.time);
self.toasts.push(now, text, error);
}
/// Drain everything the background side has said since the last frame.
///
/// Takes `frame` because starting a player is one of the things that can
/// come out of this channel, and building the mpv render context needs
/// eframe's OpenGL context — which is current here and nowhere else. See
/// [`crate::playback`].
fn pump(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
while let Ok(event) = self.events.try_recv() {
match event {
Event::Shares(shares) => self.shares = shares,
Event::ShareAdded(name) => {
self.form = ShareForm::default();
self.note(ctx, format!("added {name}"), false);
// 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::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<String> {
let playback = self.playback.as_ref()?;
if let Some((id, cover)) = &self.media_cover
&& *id == playback.id
{
return cover.clone();
}
let cover = self
.metadata
.get(&playback.target.title_key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| {
found
.poster_url
.as_deref()
.or_else(|| found.tile_art().map(|(url, _)| url))
})
.and_then(|url| self.engine.poster_file(url))
.map(|path| format!("file://{}", path.display()));
self.media_cover = Some((playback.id, cover.clone()));
cover
}
/// 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<PlaybackTarget> {
let target = &self.playback.as_ref()?.target;
let title = self.library.get(&target.title_key)?;
let episode = match direction {
Adjacent::Previous => title.preceding(&target.share_id, &target.link_id),
Adjacent::Next => title.following(&target.share_id, &target.link_id),
}?;
Some(PlaybackTarget::new(title, episode))
}
/// What the provider calls the episode this target is, if anything.
fn episode_name(&self, target: &PlaybackTarget) -> Option<String> {
let number = target.number?;
self.episodes
.get(&target.title_key)?
.get(target.season, number)?
.name
.clone()
}
/// Whether there is anything to step to in either direction, for the
/// controls that offer it.
fn neighbours(&self) -> ui::transport::Neighbours {
ui::transport::Neighbours {
previous: self.adjacent(Adjacent::Previous).is_some(),
next: self.adjacent(Adjacent::Next).is_some(),
}
}
/// Advance the end-of-episode countdown, and say what the card should show.
///
/// Called once a frame from the player page, *before* it is drawn, because
/// drawing does not mutate — the card is told how many seconds are left and
/// nothing more. Returns `None` whenever there is nothing to offer, which
/// is nearly always.
///
/// Four things have to hold before a viewer is interrupted, and each of
/// them is a way this has gone wrong elsewhere: the file has to actually be
/// in its credits, there has to be a next episode to go to, autoplay has to
/// be on, and playback has to be *running* — a paused episode is one
/// somebody walked away from, and coming back to the next one already
/// playing is the opposite of helpful.
fn tick_up_next(
&mut self,
ctx: &egui::Context,
actions: &mut Vec<Action>,
) -> Option<UpNextCard> {
let has_next = self.neighbours().next;
let autoplay = self.engine.playback_prefs().autoplay_next;
let next = self.adjacent(Adjacent::Next);
let Some(playback) = &self.playback else {
self.up_next = UpNext::default();
return None;
};
if self.up_next.playback_id != playback.id {
self.up_next = UpNext {
playback_id: playback.id,
..UpNext::default()
};
}
// 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,
})
}
/// Near the end of a file, fetch the opening blocks of the next one.
///
/// The last two minutes, or the credits if the chapters say where they
/// are — whichever comes first. Late enough that a viewer who stops after
/// one episode has not cost a download, early enough that the swap starts
/// from the cache.
fn warm_next(&mut self) {
/// How close to the end counts as "about to want the next one".
const LEAD_SECONDS: f64 = 120.0;
let Some(playback) = &self.playback else {
return;
};
if playback.id == self.warmed || !playback.loaded {
return;
}
let near_end = playback
.duration
.is_some_and(|duration| duration - playback.position < LEAD_SECONDS);
if !(near_end || playback.in_credits()) {
return;
}
self.warmed = playback.id;
if let Some(next) = self.adjacent(Adjacent::Next) {
self.engine.warm(&next);
}
}
/// Seek past an opening or credits on arrival, when the viewer asked for
/// that.
///
/// Once per chapter per file. A viewer who skipped back into the opening
/// wants to hear it.
fn auto_skip(&mut self, ctx: &egui::Context) {
let Some(playback) = &self.playback else {
return;
};
if !playback.loaded || playback.paused || playback.seeking {
return;
}
let Some((label, end)) = playback.skippable() else {
return;
};
let Some(index) = playback.chapter().map(|chapter| chapter.index) else {
return;
};
if self.auto_skipped.0 != playback.id {
self.auto_skipped = (playback.id, Vec::new());
}
if self.auto_skipped.1.contains(&index) || !self.engine.playback_prefs().auto_skip {
return;
}
self.auto_skipped.1.push(index);
playback.send(crate::playback::Command::SeekTo(end));
// Said, because a jump nobody asked for this second reads as a glitch.
let what = label.trim_start_matches("Skip ").to_lowercase();
self.note(ctx, format!("skipped the {what}"), false);
}
/// Start the next episode, if there is one and the viewer wants it.
///
/// Called on a clean end of file only. A file that failed to load has an
/// end too, and auto-advancing through a broken share one episode at a
/// time is how a player earns being turned off.
fn autoplay_next(&mut self, ctx: &egui::Context) {
if !self.engine.playback_prefs().autoplay_next {
return;
}
// 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) {
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(_)) {
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,
// 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, "screenshot saved".to_owned());
}
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.flash(
ctx,
if seconds < 0.0 {
format!("− {:.0} s", -seconds)
} else {
format!("+ {seconds:.0} s")
},
);
}
if let Some(playback) = &self.playback {
playback.send(command);
}
}
Action::PlayAdjacent(direction) => match self.adjacent(direction) {
Some(target) => self.apply(ctx, Action::Play(target)),
None => self.note(
ctx,
match direction {
Adjacent::Previous => "this is the first one",
Adjacent::Next => "this is the last one",
},
false,
),
},
Action::SetSpeed(speed) => {
let mut prefs = self.engine.playback_prefs();
prefs.speed = speed;
self.engine.set_playback_prefs(prefs, true);
let speed = self.engine.playback_prefs().speed;
if let Some(playback) = &mut self.playback {
playback.speed = speed;
playback.send(crate::playback::Command::SetSpeed(speed));
}
let text = format!("Speed {}", ui::format_speed(speed));
if self.page == Page::Player {
self.flash(ctx, text);
} else {
self.note(ctx, text, false);
}
}
Action::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::ToggleEpisodes => self.episode_drawer = !self.episode_drawer,
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, text);
}
}
Action::ResetSubtitleDelay => {
if let Some(playback) = &mut self.playback {
playback.set_subtitle_delay(0.0);
self.flash(ctx, "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(ctx, format!("Volume {volume:.0}%"));
}
}
Action::ToggleMute => {
let mut prefs = self.engine.playback_prefs();
// From what is playing, not from the preferences: mpv is the
// one that knows, and a keypress in its own window changes it
// without going through here.
prefs.muted = match &self.playback {
Some(playback) => !playback.muted,
None => !prefs.muted,
};
self.send_player(crate::playback::Command::SetMuted(prefs.muted));
self.flash(ctx, if prefs.muted { "Muted" } else { "Sound on" });
self.engine.set_playback_prefs(prefs, true);
}
Action::SelectTrack { kind, id } => {
self.send_player(crate::playback::Command::SelectTrack(kind, id));
// Remember the language and the track's name, not its number:
// the next episode is a different file, where track 3 may be a
// commentary and Japanese may be track 2. The name is what
// tells "Signs & Songs" from "Full Subtitles" in one language.
let (language, title) = id
.and_then(|id| {
self.playback.as_ref().and_then(|playback| {
playback
.tracks_of(kind)
.find(|track| track.id == id)
.map(|track| (track.language.clone(), track.title.clone()))
})
})
.unwrap_or_default();
if let Some(playback) = &self.playback {
let mut show = self
.engine
.title_track_prefs(&playback.target.title_key)
.unwrap_or_default();
match kind {
pstr_player::TrackKind::Audio => {
show.audio_language = language;
show.audio_title = title;
}
pstr_player::TrackKind::Subtitle => {
show.subtitles = id.is_some();
if id.is_some() {
show.subtitle_language = language;
show.subtitle_title = title;
}
}
pstr_player::TrackKind::Video => {}
}
self.engine
.set_title_track_prefs(playback.target.title_key.clone(), show);
}
}
}
}
/// The bar across the top: where you are, and what to search.
fn navigation(
&self,
ui: &mut egui::Ui,
actions: &mut Vec<Action>,
search: &mut String,
focus_search: bool,
) {
ui.horizontal(|ui| {
ui.add_space(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(_));
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);
ui::search_field(ui, search, focus_search);
}
});
});
}
}
impl eframe::App for App {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
// Before the timer: this is the frame budget being spent waiting, not
// the frame taking longer, and `FrameTimer` reports slow frames.
self.pacer.wait();
let _timer = FrameTimer::new(&self.page);
let ctx = ui.ctx().clone();
let ctx = &ctx;
self.pump(ctx, frame);
// 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.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx);
let mut actions: Vec<Action> = 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.episode_drawer,
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,
episode_drawer,
..
} = 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,
}
});
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,
},
open: *episode_drawer,
});
// No panels: the controls are drawn over the picture, and a nav bar
// above a film is the one thing every player agrees not to do.
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,
},
&mut actions,
);
});
if let Some((text, at)) = &self.osd
&& !ui::player::osd(ctx, text, *at)
{
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,
now: crate::engine::unix_now(),
},
);
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),
on_list: view.listed.contains(key.as_str()),
},
ui::title::OfflineView {
downloads,
files: offline_files,
},
&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<std::path::PathBuf> {
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<Page>, 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, usize>) -> String {
let found: usize = progress.values().sum();
let which = match progress.len() {
0 => return "crawling".to_owned(),
1 => progress.keys().next().cloned().unwrap_or_default(),
shares => format!("{shares} shares"),
};
if found == 0 {
format!("crawling {which}")
} else {
format!("crawling {which} · {found} found")
}
}
/// 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<String, MetadataRecord>,
after: &HashMap<String, MetadataRecord>,
) -> Vec<String> {
// Every picture a title is drawn with, in the order of the keys below.
let urls = |records: &HashMap<String, MetadataRecord>, 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<String> = 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<Action>) {
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(_)) && 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<f64>,
}
/// The speeds `[` and `]` step through. Fixed steps rather than a nudge of a
/// tenth, because 1.0 has to be somewhere a viewer can land on again.
const SPEEDS: [f64; 8] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 3.0];
/// The next step from `speed` in `direction`, from wherever it is now.
fn step_speed(speed: f64, faster: bool) -> f64 {
if faster {
SPEEDS
.iter()
.copied()
.find(|&step| step > speed + 1e-6)
.unwrap_or(SPEEDS[SPEEDS.len() - 1])
} else {
SPEEDS
.iter()
.rev()
.copied()
.find(|&step| step < speed - 1e-6)
.unwrap_or(SPEEDS[0])
}
}
fn shortcuts(
ctx: &egui::Context,
fullscreen: bool,
drawer: bool,
playing: Option<PlayerKeys>,
actions: &mut Vec<Action>,
) {
use crate::playback::Command;
use egui::Key;
/// What one press of the volume keys is worth. mpv's own step, and small
/// enough that holding the key is a ramp rather than a switch.
const VOLUME_STEP: f64 = 5.0;
// 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<Key> = ctx.input(|input| {
[
Key::Space,
Key::K,
Key::ArrowLeft,
Key::ArrowRight,
Key::ArrowUp,
Key::ArrowDown,
Key::M,
Key::N,
Key::P,
Key::F,
Key::S,
Key::OpenBracket,
Key::CloseBracket,
Key::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::ToggleEpisodes),
// The episode list is the innermost thing open, so it goes first.
Key::Escape if drawer => actions.push(Action::ToggleEpisodes),
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<Action>,
) {
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"
]
);
}
}