mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
3254 lines
132 KiB
Rust
3254 lines
132 KiB
Rust
//! 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"
|
||
]
|
||
);
|
||
}
|
||
}
|