This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
[package]
name = "pstr-app"
description = "The proton-stream desktop application."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[[bin]]
name = "proton-stream"
path = "src/main.rs"
[dependencies]
pstr-core.workspace = true
pstr-meta.workspace = true
pstr-stream.workspace = true
pstr-player.workspace = true
tokio.workspace = true
futures.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
eframe.workspace = true
egui.workspace = true
image.workspace = true
sha2.workspace = true
parking_lot.workspace = true
+178
View File
@@ -0,0 +1,178 @@
//! Smoke test for the embedded video path, with no Proton account in the way.
//!
//! Everything from `mpv_render_context_create` to the `painter.image` call is
//! exercised here — the GL entry-point loader, the framebuffer, the orientation
//! of the texture and the fact that egui can draw over the result. What is *not*
//! exercised is anything to do with shares, blocks or decryption, which is the
//! point: when the picture is wrong, this says whether the problem is in the
//! render path or above it.
//!
//! ```bash
//! cargo run -p pstr-app --example embedded_video # a test pattern
//! cargo run -p pstr-app --example embedded_video -- /path.mkv # a real file
//! ```
//!
//! What to look for: a moving picture inside the bordered rectangle, right way
//! up, with the chrome around it drawn normally and the frame counter climbing.
//! A stuck counter means the update callback is not firing; an upside-down
//! picture means the `flip` argument in `VideoRenderer::render` is wrong; a
//! black rectangle with a healthy counter means the framebuffer is not the one
//! being sampled.
use std::sync::Arc;
use anyhow::Context as _;
use pstr_app::video::VideoSurface;
use pstr_player::{Player, PlayerConfig, PlayerEvent, VideoOutput};
/// mpv's own test source: a moving pattern, generated with no file on disk.
const TEST_PATTERN: &str = "av://lavfi:testsrc=size=1280x720:rate=30";
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pstr_app=debug,pstr_player=debug".into()),
)
.init();
let url = std::env::args()
.nth(1)
.unwrap_or_else(|| TEST_PATTERN.into());
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?,
);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("embedded video smoke test")
.with_inner_size([960.0, 640.0]),
..Default::default()
};
eframe::run_native(
"embedded_video",
options,
Box::new(move |cc| Ok(Box::new(Harness::new(cc, runtime, url)?) as Box<dyn eframe::App>)),
)
.map_err(|error| anyhow::anyhow!("run the window: {error}"))
}
struct Harness {
player: Arc<Player>,
video: Option<VideoSurface>,
url: String,
/// Set once, after the first frame: the render context has to exist before
/// the file is loaded, and it can only be built with the GL context current.
started: bool,
frames: u64,
last_event: String,
}
impl Harness {
fn new(
cc: &eframe::CreationContext<'_>,
runtime: Arc<tokio::runtime::Runtime>,
url: String,
) -> anyhow::Result<Self> {
let config = PlayerConfig {
video: VideoOutput::Embedded,
on_screen_controller: false,
default_keybindings: false,
..PlayerConfig::default()
};
let player = Arc::new(Player::new(runtime, config)?);
let gl = cc
.gl
.clone()
.context("eframe was built without a glow context")?;
// SAFETY: `CreationContext` is handed to us on the UI thread with the
// context current, which is the same guarantee `App::ui` gives.
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
Ok(Self {
player,
video: Some(video),
url,
started: false,
frames: 0,
last_event: "—".into(),
})
}
}
impl eframe::App for Harness {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if !self.started {
self.started = true;
if let Err(error) = self.player.play_url(&self.url) {
self.last_event = format!("load failed: {error}");
}
}
// Polled from the UI thread rather than a player thread: this harness is
// about the picture, and a zero timeout keeps the draw loop honest.
while let Some(event) = self.player.poll_event(0.0) {
if !matches!(event, PlayerEvent::Position(_) | PlayerEvent::Other) {
self.last_event = format!("{event:?}");
}
}
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("embedded video");
ui.label(&self.url);
ui.horizontal(|ui| {
ui.label(format!("frames drawn: {}", self.frames));
ui.separator();
ui.label(format!("last event: {}", self.last_event));
ui.separator();
ui.label(match self.player.position() {
Some(position) => format!("at {position:.1}s"),
None => "no position yet".into(),
});
});
ui.add_space(8.0);
// Deliberately inset and bordered: a full-window picture would hide
// whether the rectangle is respected, which is the thing most likely
// to be wrong.
let rect = ui.available_rect_before_wrap().shrink(24.0);
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(4), egui::Color32::DARK_GRAY);
let pixels_per_point = ui.ctx().pixels_per_point();
let size = [
(rect.width() * pixels_per_point).round() as i32,
(rect.height() * pixels_per_point).round() as i32,
];
if let Some(video) = self.video.as_mut()
&& let Some(texture) = video.texture(frame, size)
{
self.frames += 1;
ui.painter().image(
texture,
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
ui.painter().rect_stroke(
rect,
egui::CornerRadius::same(4),
egui::Stroke::new(2.0, egui::Color32::from_rgb(0x7d, 0x4d, 0xff)),
egui::StrokeKind::Outside,
);
});
}
/// The surface holds GL objects and mpv's render context; both have to go
/// while the context is still current.
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.video = None;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
//! The `proton-stream` desktop application, as a library.
//!
//! A window over one or more Proton Drive public links: crawl them once, and
//! what is behind them becomes a library that plays without downloading first.
//!
//! Three threads' worth of work, kept apart on purpose:
//!
//! * **The UI thread** draws. It never opens a share, reads the catalog or
//! decodes an image — see [`engine`]. It is also the only thread that may
//! touch OpenGL, which is why [`video`] and the construction of a
//! [`playback::Playback`] both live on it.
//! * **The tokio runtime** does everything with a network or a disk in it. It is
//! built by the binary rather than with `#[tokio::main]`, because
//! [`pstr_player`] needs an `Arc<Runtime>` it can block on from mpv's demuxer
//! thread, which is outside tokio entirely.
//! * **A player thread** drives mpv for the length of a file — see [`playback`].
//!
//! This is a library as well as a binary so the parts that can only be checked
//! against a real GPU are reachable from `examples/` — see
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
pub mod app;
pub mod engine;
pub mod playback;
pub mod theme;
pub mod ui;
pub mod video;
+46
View File
@@ -0,0 +1,46 @@
//! `proton-stream` — the desktop application.
//!
//! Everything is in the library beside this file; what is here is the process:
//! logging, the tokio runtime, the window, and the app inside it. See
//! [`pstr_app`] for how the three threads divide the work.
use anyhow::Context;
use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pstr_app=info,pstr_core=info,pstr_stream=info".into()),
)
.init();
let runtime = std::sync::Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("build the tokio runtime")?,
);
let dirs = AppDirs::ensure().context("resolve app directories")?;
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("proton-stream")
.with_inner_size([1180.0, 780.0])
.with_min_inner_size([720.0, 480.0])
.with_app_id("io.narl.proton-stream"),
..Default::default()
};
eframe::run_native(
"proton-stream",
options,
Box::new(move |cc| {
pstr_app::app::App::new(cc, runtime, dirs)
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(|error| error.into())
}),
)
// `eframe::Error` is not `Send + Sync`, which `anyhow` wants of a source.
.map_err(|error| anyhow::anyhow!("run the window: {error}"))
}
+628
View File
@@ -0,0 +1,628 @@
//! The player, driven from the UI without blocking it.
//!
//! [`pstr_player::Player`] is polled, not callback-driven: something has to sit
//! in `poll_event` for the life of the file. That cannot be the UI thread, so a
//! dedicated thread owns the `Player` outright — it is the only thing that ever
//! touches mpv — and the UI talks to it through a command channel and hears back
//! as [`Event::Player`].
//!
//! ```text
//! ui thread ──Command::Seek──▶ player thread ──▶ mpv
//! ▲ │ │
//! │ └─ VideoSurface ───┤ frames, on the UI thread's GL context
//! └──── Event::Player(Position) ──┘ + watch state, every few seconds
//! ```
//!
//! The picture is drawn inside this window, which is why the `Player` is built
//! *here* rather than on the player thread: `mpv_render_context_create` has to
//! run on the thread whose OpenGL context it renders into, and it has to run
//! before the file is loaded, or mpv's video output has nowhere to send its
//! first frames. So the UI thread creates the player and its
//! [`crate::video::VideoSurface`], and only then hands the player to the thread
//! that will poll it. Both hold an `Arc`; mpv is destroyed when the last one
//! goes, which is always the surface, on the UI thread.
//!
//! Without a usable OpenGL context — which should not happen under `eframe`'s
//! glow backend, but is cheap to survive — the player falls back to letting mpv
//! open a window of its own, exactly as `pstr play` does.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Sender, TryRecvError, channel};
use eframe::glow;
use pstr_core::catalog::CatalogNode;
use pstr_core::library::{Episode, Title};
use pstr_core::prefs::PlaybackPrefs;
use pstr_player::{
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
VideoOutput,
};
use pstr_stream::VideoStream;
use crate::engine::{Engine, Event, watch_state};
use crate::video::VideoSurface;
/// How often playback position is written to the catalog.
///
/// Every position event would be a write per frame-ish; only on shutdown would
/// lose the position when mpv is killed. Five seconds is the compromise, and it
/// is what the viewer loses at worst.
const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
/// How long the player thread waits for mpv before checking for a command.
/// Short enough that a click on pause feels immediate.
const POLL_TIMEOUT: f64 = 0.05;
/// What is being played, and where it came from.
#[derive(Debug, Clone)]
pub struct PlaybackTarget {
pub share_id: String,
pub volume_id: String,
pub link_id: String,
/// The file name, for mpv's window title.
pub name: String,
/// The title this belongs to, for routing back to its page.
pub title_key: String,
/// What the transport bar shows.
pub title_name: String,
pub subtitle: String,
/// The numbering the filename states, for looking the episode up.
pub season: Option<u32>,
pub number: Option<u32>,
/// What the provider calls this episode, once someone has looked. Filled in
/// by the app rather than here: this type is built in the pages, and the
/// answers live next to the library.
pub episode_name: Option<String>,
/// Where to resume, when the viewer had been here before.
pub resume_at: Option<f64>,
}
impl PlaybackTarget {
/// The target for one episode of a title, resuming where it left off.
pub fn new(title: &Title, episode: &Episode) -> Self {
Self::from_node(title, &episode.node, episode.resume_at())
}
/// The target for one file, starting at `resume_at`.
pub fn from_node(title: &Title, node: &CatalogNode, resume_at: Option<f64>) -> Self {
Self {
share_id: node.share_id.clone(),
volume_id: node.volume_id.clone(),
link_id: node.link_id.clone(),
name: node.name.clone(),
title_key: title.key.clone(),
title_name: title.name.clone(),
subtitle: Episode {
node: node.clone(),
watch: None,
}
.label(),
season: node.parsed.season,
number: node.parsed.episode,
episode_name: None,
resume_at,
}
}
/// The line under the title while this plays: the numbering, and the
/// episode's name when a provider has given one.
pub fn caption(&self) -> String {
match &self.episode_name {
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
Some(name) => name.clone(),
None => self.subtitle.clone(),
}
}
}
/// What the UI can ask the player to do.
#[derive(Debug, Clone, Copy)]
pub enum Command {
TogglePause,
SeekBy(f64),
SeekTo(f64),
/// 0–100.
SetVolume(f64),
SetMuted(bool),
/// Play this track of that kind, or `None` for none of it.
///
/// The player also takes the chosen track's language as the preference for
/// every file it loads afterwards, so picking Japanese audio for one
/// episode picks it for the next one too.
SelectTrack(TrackKind, Option<i64>),
Stop,
}
/// Identifies one player instance for the length of its file.
///
/// Starting a file while another is playing leaves two players alive for a
/// moment — the old one is still shutting down. Without an id its parting
/// events would land on the new one's transport bar and its `PlayerStopped`
/// would clear it.
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A running player, from the UI's side.
pub struct Playback {
pub id: u64,
commands: Sender<Command>,
/// Set by the player thread once it has stopped mpv and written the final
/// watch position.
finished: Arc<AtomicBool>,
/// Where mpv's frames land, when the picture is inside this window. `None`
/// means mpv has a window of its own.
pub video: Option<VideoSurface>,
pub target: PlaybackTarget,
pub position: f64,
pub duration: Option<f64>,
pub paused: bool,
/// 0–100, as mpv has it.
pub volume: f64,
pub muted: bool,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
pub chapters: Vec<Chapter>,
/// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change.
pub roles: Vec<ChapterRole>,
/// Where the run of credits and preview that ends the file begins, when it
/// has one. What the "up next" countdown waits for.
pub credits_at: Option<f64>,
/// Set once mpv has demuxed the file. Before that there is nothing to seek
/// within and no duration to show.
pub loaded: bool,
/// A seek was issued and the picture has not come back yet — the thing
/// worth showing a spinner for on a link this slow.
pub seeking: bool,
}
impl Playback {
/// Build a player for `stream` and start it on its own thread.
///
/// Returns immediately; the first frame lands when mpv gets to it. `gl` is
/// eframe's context — with it the picture is drawn in this window, without
/// it mpv opens its own.
///
/// Must be called from the UI thread, inside the draw function: creating the
/// render context needs `gl` to be current, and eframe only guarantees that
/// around [`eframe::App::ui`].
pub fn start(
engine: &Engine,
target: PlaybackTarget,
stream: VideoStream,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<Self, String> {
let prefs = engine.playback_prefs();
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
let (commands, orders) = channel::<Command>();
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let finished = Arc::new(AtomicBool::new(false));
let thread_engine = engine.clone();
let thread_target = target.clone();
let thread_finished = Arc::clone(&finished);
let thread_player = Arc::clone(&player);
std::thread::Builder::new()
.name("pstr-player".into())
.spawn(move || {
run(
id,
thread_engine,
thread_target,
stream,
thread_player,
orders,
thread_finished,
);
})
// Only fails when the OS refuses a thread, at which point the app has
// larger problems than this file.
.expect("spawn the player thread");
Ok(Self {
id,
commands,
finished,
video,
paused: false,
volume: prefs.volume,
muted: prefs.muted,
tracks: Vec::new(),
chapters: Vec::new(),
roles: Vec::new(),
credits_at: None,
position: target.resume_at.unwrap_or(0.0),
duration: None,
loaded: false,
seeking: false,
target,
})
}
/// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool {
self.video.is_some()
}
/// Ask the player to do something. A dead player is not an error — the
/// window may simply have been closed a frame ago.
pub fn send(&self, command: Command) {
let _ = self.commands.send(command);
}
/// The tracks of one kind, in the order the file lists them.
pub fn tracks_of(&self, kind: TrackKind) -> impl Iterator<Item = &Track> {
self.tracks.iter().filter(move |track| track.kind == kind)
}
/// Which track of a kind is playing, if any. `None` for subtitles that are
/// off, which is an ordinary state rather than a missing answer.
pub fn selected_track(&self, kind: TrackKind) -> Option<&Track> {
self.tracks_of(kind).find(|track| track.selected)
}
/// The chapter the playhead is in, if the file has any.
pub fn chapter(&self) -> Option<&Chapter> {
let index = pstr_player::chapter_at(&self.chapters, self.position)?;
self.chapters.get(index)
}
/// The one thing worth offering to skip right now: the opening, the ending
/// or a next-episode preview the viewer is sitting in.
///
/// Returns what the button should say and where it should seek to. `None`
/// most of the time, which is what keeps it from being another permanent
/// control on top of the picture. The role comes from
/// [`pstr_player::roles`], not from the chapter's name alone, so a chapter
/// called `Intro` that is ten minutes of story offers nothing.
pub fn skippable(&self) -> Option<(&'static str, f64)> {
let index = pstr_player::chapter_at(&self.chapters, self.position)?;
let label = self.roles.get(index)?.skip_label()?;
let end = pstr_player::chapter_end(&self.chapters, index, self.duration)?;
// Nothing to skip once the end of it is behind us, which happens for a
// last chapter whose "end" is the duration.
(end > self.position + 0.5).then_some((label, end))
}
/// Whether the playhead is inside the run of credits and preview that ends
/// the file — what "the episode is over" means when a file states its
/// chapters.
pub fn in_credits(&self) -> bool {
self.credits_at
.is_some_and(|start| self.position + f64::EPSILON >= start)
}
/// Re-read what the chapters are, now that either they or the duration have
/// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things.
fn reclassify(&mut self) {
self.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
}
/// Fold an mpv event into what the transport bar shows.
pub fn apply(&mut self, event: &PlayerEvent) {
match event {
PlayerEvent::FileLoaded => self.loaded = true,
PlayerEvent::Volume(volume) => self.volume = *volume,
PlayerEvent::Muted(muted) => self.muted = *muted,
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(),
PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone();
self.reclassify();
}
PlayerEvent::Position(position) => {
self.position = *position;
self.seeking = false;
}
PlayerEvent::Duration(duration) => {
self.duration = Some(*duration);
self.reclassify();
}
PlayerEvent::Paused(paused) => self.paused = *paused,
PlayerEvent::Seek => self.seeking = true,
PlayerEvent::PlaybackRestart => self.seeking = false,
_ => {}
}
}
/// Stop the player and wait, briefly, for it to write the final position.
///
/// Called as the window closes: the runtime is about to go away with the
/// process, so a save that has only been *spawned* may never run. The wait
/// is bounded — a wedged mpv must not hold the app open.
pub fn stop_and_wait(&self, timeout: std::time::Duration) {
self.send(Command::Stop);
let deadline = std::time::Instant::now() + timeout;
while !self.finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?;
Some((self.position / duration).clamp(0.0, 1.0) as f32)
}
}
/// Create the mpv instance, and the surface it draws into when there is one.
///
/// The fallback is deliberate rather than defensive: if the render context
/// cannot be created — a driver without the GL version mpv needs, a context
/// eframe built in a way `pstr_player::VideoRenderer` cannot resolve entry
/// points for — the player is rebuilt for a window of its own. A film in the
/// wrong window beats an error message.
fn build_player(
engine: &Engine,
target: &PlaybackTarget,
prefs: &PlaybackPrefs,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
let title = format!("proton-stream — {}", target.name);
if let Some(gl) = gl {
let config = PlayerConfig {
video: VideoOutput::Embedded,
window_title: title.clone(),
// This window draws the transport and owns the keyboard; mpv's own
// controller would be a second set of controls over the same file.
on_screen_controller: false,
default_keybindings: false,
..from_prefs(prefs)
};
match Player::new(engine.runtime().clone(), config) {
Ok(player) => {
let player = Arc::new(player);
// SAFETY: called from `App::ui`, where eframe has made this
// context current, and the surface is dropped from the same
// place — see `video.rs`.
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
{
Ok(video) => return Ok((player, Some(video))),
Err(error) => {
tracing::warn!("embed the video: {error}; falling back to a window");
// Dropping the player here destroys the mpv core that
// was configured for `vo=libmpv`, which would show
// nothing at all now that there is no render context.
drop(player);
}
}
}
Err(error) => {
tracing::warn!("start an embedded mpv: {error}; falling back to a window");
}
}
}
let config = PlayerConfig {
window_title: title,
..from_prefs(prefs)
};
let player = Player::new(engine.runtime().clone(), config)
.map_err(|error| format!("start mpv: {error}"))?;
Ok((Arc::new(player), None))
}
/// The player defaults with the viewer's preferences folded in.
fn from_prefs(prefs: &PlaybackPrefs) -> PlayerConfig {
PlayerConfig {
volume: prefs.volume,
muted: prefs.muted,
audio_language: prefs.audio_language.clone(),
subtitle_language: prefs.subtitle_language.clone(),
subtitles: prefs.subtitles,
..PlayerConfig::default()
}
}
/// The player thread: drives mpv, forwards its events, saves watch state.
///
/// It does not own the player — the UI thread's [`VideoSurface`] holds the other
/// `Arc`, and mpv is destroyed when that one goes. What this thread owns is the
/// *event loop*: `wait_event` has to be called from one place, for the length of
/// the file, and that place cannot be the thread that draws.
fn run(
id: u64,
engine: Engine,
target: PlaybackTarget,
stream: VideoStream,
player: Arc<Player>,
orders: std::sync::mpsc::Receiver<Command>,
finished: Arc<AtomicBool>,
) {
let handle = match player.play(stream) {
Ok(handle) => handle,
Err(error) => {
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
finished.store(true, Ordering::Release);
engine.emit(Event::PlayerStopped { id });
return;
}
};
let mut position = target.resume_at.unwrap_or(0.0);
let mut duration: Option<f64> = None;
let mut watched = false;
let mut resumed = target.resume_at.is_none();
let mut last_save = std::time::Instant::now();
'playback: loop {
match orders.try_recv() {
Ok(command) => {
if !apply_command(&engine, id, &player, command, position) {
break 'playback;
}
continue;
}
Err(TryRecvError::Empty) => {}
// The UI dropped its end: it is not going to ask for anything else.
// Not a reason to stop — mpv's own window may still be up, and if
// the picture is embedded the surface is about to drop the player
// anyway, which ends this loop through `Shutdown`.
Err(TryRecvError::Disconnected) => {}
}
let Some(event) = player.poll_event(POLL_TIMEOUT) else {
continue;
};
match &event {
PlayerEvent::FileLoaded => {
duration = player.duration();
// The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection, which
// is what the menus have to open showing.
emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at {
resumed = true;
if let Err(error) = player.seek_to(at) {
tracing::warn!("resume at {at}: {error}");
}
}
}
PlayerEvent::Duration(value) => duration = Some(*value),
PlayerEvent::Position(value) => {
position = *value;
if last_save.elapsed() >= SAVE_EVERY {
last_save = std::time::Instant::now();
save(&engine, &target, position, duration, watched);
}
}
// Played to the end: mark it seen, and store the duration as the
// position so nothing offers to resume the last ten seconds.
PlayerEvent::EndFile(EndReason::Eof) => {
watched = true;
position = duration.unwrap_or(position);
}
_ => {}
}
let stop = matches!(event, PlayerEvent::Shutdown)
|| matches!(
event,
PlayerEvent::EndFile(EndReason::Eof | EndReason::Failed)
);
engine.emit(Event::Player { id, event });
if stop {
break;
}
}
// Synchronously, not spawned: the runtime may be going away with the
// process a moment from now.
engine.save_watch_state_now(
&target.share_id,
&target.link_id,
&watch_state(position, duration, watched),
);
// The stream can go now. The player cannot: the UI thread's surface holds
// the other `Arc`, and mpv must be destroyed there, on the thread whose
// OpenGL context its render context was built against.
drop(handle);
drop(player);
engine.release(
target.share_id.clone(),
target.volume_id.clone(),
target.link_id.clone(),
);
finished.store(true, Ordering::Release);
engine.emit(Event::PlayerStopped { id });
}
/// Returns whether playback should continue.
fn apply_command(
engine: &Engine,
id: u64,
player: &Player,
command: Command,
position: f64,
) -> bool {
let result = match command {
Command::TogglePause => player
.is_paused()
.and_then(|paused| player.set_paused(!paused)),
Command::SeekBy(delta) => player.seek_by(delta),
Command::SeekTo(seconds) => player.seek_to(seconds),
Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
Command::Stop => {
let _ = player.quit();
return false;
}
};
if let Err(error) = result {
tracing::warn!("player command at {position:.0}s: {error}");
}
true
}
/// Switch track, then carry the choice forward to the files after this one.
///
/// The language is read back from mpv rather than taken from what the UI knew,
/// because between the click and here the selection is mpv's to interpret — and
/// a track with no language tag has to clear the preference rather than leave
/// the old one standing over a choice that contradicts it.
fn select_track(
engine: &Engine,
id: u64,
player: &Player,
kind: TrackKind,
track: Option<i64>,
) -> pstr_player::Result<()> {
player.select_track(kind, track)?;
let tracks = player.tracks();
let language = track
.and_then(|track| {
tracks
.iter()
.find(|candidate| candidate.kind == kind && candidate.id == track)
})
.and_then(|track| track.language.clone());
if let Err(error) = player.prefer_language(kind, language.as_deref()) {
tracing::warn!("prefer {} {language:?}: {error}", kind.label());
}
engine.emit(Event::Player {
id,
event: PlayerEvent::Tracks(tracks),
});
Ok(())
}
/// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player {
id,
event: PlayerEvent::Tracks(player.tracks()),
});
engine.emit(Event::Player {
id,
event: PlayerEvent::Chapters(player.chapters()),
});
}
fn save(
engine: &Engine,
target: &PlaybackTarget,
position: f64,
duration: Option<f64>,
watched: bool,
) {
engine.save_watch_state(
target.share_id.clone(),
target.link_id.clone(),
watch_state(position, duration, watched),
);
}
+81
View File
@@ -0,0 +1,81 @@
//! The look: one dark palette, applied once at startup.
//!
//! Deliberately dark and low-contrast around the edges — this is a window full
//! of video stills, and every bit of chrome brightness competes with them. The
//! accent is Proton's purple, which is also the only strong colour in the app,
//! so anything wearing it is something to click.
use egui::{Color32, CornerRadius, Stroke, Visuals};
pub const BACKGROUND: Color32 = Color32::from_rgb(0x0e, 0x0e, 0x12);
pub const SURFACE: Color32 = Color32::from_rgb(0x17, 0x17, 0x1d);
pub const CARD: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x26);
pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x35);
pub const TEXT: Color32 = Color32::from_rgb(0xea, 0xea, 0xf0);
pub const MUTED: Color32 = Color32::from_rgb(0x8e, 0x8e, 0x9c);
pub const ACCENT: Color32 = Color32::from_rgb(0x7d, 0x4d, 0xff);
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x53, 0x33, 0xad);
pub const DANGER: Color32 = Color32::from_rgb(0xe0, 0x55, 0x61);
/// Card geometry. The grid is built from these, so a change here moves
/// everything together.
pub const CARD_WIDTH: f32 = 232.0;
/// Video stills are 16:9. A poster shape would letterbox every one of them.
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0;
pub fn apply(ctx: &egui::Context) {
let mut visuals = Visuals::dark();
visuals.panel_fill = BACKGROUND;
visuals.window_fill = SURFACE;
visuals.extreme_bg_color = Color32::from_rgb(0x0a, 0x0a, 0x0d);
visuals.faint_bg_color = SURFACE;
visuals.override_text_color = Some(TEXT);
visuals.selection.bg_fill = ACCENT_DIM;
visuals.selection.stroke = Stroke::new(1.0, TEXT);
visuals.hyperlink_color = ACCENT;
let radius = CornerRadius::same(8);
visuals.widgets.noninteractive.bg_fill = SURFACE;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke =
Stroke::new(1.0, Color32::from_rgb(0x26, 0x26, 0x30));
visuals.widgets.inactive.bg_fill = CARD;
visuals.widgets.inactive.weak_bg_fill = CARD;
visuals.widgets.inactive.corner_radius = radius;
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
visuals.widgets.hovered.bg_fill = CARD_HOVER;
visuals.widgets.hovered.weak_bg_fill = CARD_HOVER;
visuals.widgets.hovered.corner_radius = radius;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
visuals.widgets.active.bg_fill = ACCENT_DIM;
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
visuals.widgets.active.corner_radius = radius;
ctx.set_visuals(visuals);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
style.spacing.button_padding = egui::vec2(12.0, 7.0);
style.spacing.interact_size.y = 30.0;
style.spacing.scroll.bar_width = 10.0;
use egui::FontFamily::Proportional;
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
style.text_styles = [
(Heading, egui::FontId::new(26.0, Proportional)),
(Body, egui::FontId::new(14.0, Proportional)),
(Button, egui::FontId::new(14.0, Proportional)),
(Small, egui::FontId::new(11.5, Proportional)),
(
Monospace,
egui::FontId::new(12.5, egui::FontFamily::Monospace),
),
]
.into();
});
}
+191
View File
@@ -0,0 +1,191 @@
//! The library page: what to watch, as a wall of stills.
use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::Art;
use crate::ui::{self, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
search: &str,
actions: &mut Vec<Action>,
) {
if library.is_empty() {
return empty_state(ui, actions);
}
let matches = library.search(search);
let searching = !search.trim().is_empty();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !searching {
let resumable = library.continue_watching();
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
ui.add_space(16.0);
}
}
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
if matches.is_empty() {
ui.add_space(8.0);
ui.label(ui::muted("Nothing here by that name."));
return;
}
grid(ui, art, &matches, actions);
});
}
/// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt("continue")
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
for title in titles {
let Some(episode) = title.resume() else {
continue;
};
let remaining = episode
.watch
.and_then(|watch| watch.duration_secs)
.map(|duration| {
format!(
"{} left",
ui::format_time(
duration - episode.watch.map_or(0.0, |w| w.position_secs)
)
)
})
.unwrap_or_else(|| episode.label());
let clicked = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: remaining,
progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(),
},
)
.clicked();
if clicked {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
});
}
/// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let columns = ui::columns(ui.available_width());
for row in titles.chunks(columns) {
ui.horizontal(|ui| {
for title in row {
let clicked = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: (title.kind == TitleKind::Film).then(|| "Film".to_string()),
},
)
.clicked();
if clicked {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
ui.add_space(theme::CARD_GAP);
}
}
/// The grey line under a card: how much there is, and how much is left.
fn subtitle(title: &Title) -> String {
if title.kind == TitleKind::Film {
return title
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "Film".into());
}
let total = title.episode_count();
let watched = title.watched_count();
if watched == 0 {
plural(total, "episode")
} else if watched >= total {
format!("{} · watched", plural(total, "episode"))
} else {
format!("{watched} of {total} watched")
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| {
ui.add_space(120.0);
ui.label(
egui::RichText::new("Nothing in the library yet")
.size(20.0)
.strong(),
);
ui.add_space(6.0);
ui.label(ui::muted(
"Add a Proton Drive share link, and everything playable behind it shows up here.",
));
ui.add_space(18.0);
if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares));
}
});
}
pub fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("1 {noun}")
} else {
format!("{count} {noun}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title");
assert_eq!(plural(0, "title"), "0 titles");
assert_eq!(plural(12, "episode"), "12 episodes");
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Drawing. Every function here takes what it needs explicitly rather than an
//! `&mut App`, because the borrow checker is what keeps a click handler from
//! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn.
pub mod library;
pub mod player;
pub mod shares;
pub mod title;
pub mod transport;
use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord};
use crate::engine::{Engine, ImageCache, thumbnail_key};
use crate::theme;
/// Everything the pages need to draw one title's picture.
///
/// Bundled because there are now three places a tile's art can come from and
/// four call sites that need all three; passing them separately made every
/// signature in this module four arguments longer.
pub struct Art<'a> {
pub engine: &'a Engine,
/// Proton's own per-file thumbnails.
pub thumbs: &'a mut ImageCache,
/// Artwork from a metadata provider, keyed by title key.
pub posters: &'a mut ImageCache,
/// What the providers have said, keyed by title key.
pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles.
pub episodes: &'a HashMap<String, EpisodeGuide>,
}
impl Art<'_> {
/// What the provider says about one file of a title, matched on the
/// numbering its name states.
pub fn episode(&self, title_key: &str, episode: &Episode) -> Option<&EpisodeMetadata> {
let number = episode.node.parsed.episode?;
self.episodes
.get(title_key)?
.get(episode.node.parsed.season, number)
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
/// deliberate: a provider's poster is *of the title*, where a Proton
/// thumbnail is whatever frame happened to be at the start of the first
/// episode — often a black frame or a studio logo. Both beat initials.
pub fn of(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
if let Some((url, shape)) = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.tile_art())
{
let engine = self.engine;
let key = title.key.clone();
let url = url.to_string();
if let Some(texture) = self
.posters
.texture(title.key.clone(), || engine.request_poster(key, url))
{
return Some((texture, shape));
}
}
let node = title.poster_node()?;
let engine = self.engine;
let texture = self
.thumbs
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
Some((texture, ArtShape::Landscape))
}
}
/// `1:03:47`, or `4:12` for anything under an hour.
pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 {
return "--:--".into();
}
let total = seconds.round() as u64;
let (hours, minutes, secs) = (total / 3600, (total / 60) % 60, total % 60);
if hours > 0 {
format!("{hours}:{minutes:02}:{secs:02}")
} else {
format!("{minutes}:{secs:02}")
}
}
/// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut value = bytes.max(0) as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// A section title with a rule under it.
pub fn section(ui: &mut egui::Ui, text: &str) {
ui.add_space(6.0);
ui.label(
egui::RichText::new(text)
.size(17.0)
.strong()
.color(theme::TEXT),
);
ui.add_space(2.0);
}
/// Small grey text, for everything that is context rather than content.
pub fn muted(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(12.0).color(theme::MUTED)
}
/// The one button style that means "this is the action".
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).strong().color(Color32::WHITE))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same(8)),
)
}
/// What a tile shows.
pub struct Card<'a> {
/// The picture and how to fit it. `None` draws the placeholder.
pub art: Option<(egui::TextureHandle, ArtShape)>,
pub name: &'a str,
pub subtitle: String,
/// Fraction watched, drawn as a bar across the bottom of the still.
pub progress: Option<f32>,
/// A short label in the corner — `S01E04`, `Film`.
pub badge: Option<String>,
}
/// Draw one tile and report whether it was clicked.
///
/// A landscape picture is cropped to fill: a grid of differently-shaped black
/// bars reads as broken, and a video still is not a composition anyone framed,
/// so nothing is lost by trimming it. A *poster* is fitted instead, because
/// cropping a 2:3 poster to 16:9 removes most of what makes it recognisable —
/// the title, usually. The gap it leaves is filled with the card colour, which
/// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = theme::CARD_WIDTH;
let image_height = (width * theme::CARD_ASPECT).round();
// Two lines of name plus one of subtitle. Fixed, because the tile is
// allocated before the name is laid out — and because a grid whose rows are
// each a different height by title length reads as broken.
let text_height = 54.0;
let radius = CornerRadius::same(8);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
let hovered = response.hovered();
let painter = ui.painter();
painter.rect_filled(image_rect, radius, theme::CARD);
match &card.art {
Some((texture, ArtShape::Landscape)) => {
let size = texture.size_vec2();
painter.image(
texture.id(),
image_rect,
cover_uv(size, image_rect.size()),
Color32::WHITE,
);
}
Some((texture, ArtShape::Portrait)) => {
painter.image(
texture.id(),
contain_rect(texture.size_vec2(), image_rect),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
}
None => {
painter.text(
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::MUTED,
);
}
}
if let Some(badge) = &card.badge {
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
let galley = painter.layout_no_wrap(
badge.clone(),
egui::FontId::proportional(11.0),
Color32::WHITE,
);
let background = Rect::from_min_size(
anchor - Vec2::new(galley.size().x + 10.0, 0.0),
galley.size() + Vec2::new(10.0, 4.0),
);
painter.rect_filled(
background,
CornerRadius::same(4),
Color32::from_black_alpha(180),
);
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
}
if let Some(progress) = card.progress {
let track = Rect::from_min_size(
image_rect.left_bottom() - Vec2::new(0.0, 4.0),
Vec2::new(width, 4.0),
);
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
let mut played = track;
played.set_width(track.width() * progress.clamp(0.0, 1.0));
painter.rect_filled(played, CornerRadius::ZERO, theme::ACCENT);
}
if hovered {
painter.rect_stroke(
image_rect,
radius,
Stroke::new(2.0, theme::ACCENT),
egui::StrokeKind::Inside,
);
}
let text_rect = Rect::from_min_size(
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
Vec2::new(width, text_height - 6.0),
);
let painter = painter.with_clip_rect(text_rect);
// Long titles are the normal case in a release-named library, so the name
// is capped at two lines with an ellipsis rather than allowed to grow into
// the line below it.
let mut job = egui::text::LayoutJob::simple(
card.name.to_string(),
egui::FontId::proportional(14.0),
if hovered { Color32::WHITE } else { theme::TEXT },
width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
let name = painter.layout_job(job);
let name_height = name.size().y;
painter.galley(text_rect.min, name, theme::TEXT);
painter.text(
text_rect.min + Vec2::new(0.0, name_height + 2.0),
Align2::LEFT_TOP,
card.subtitle,
egui::FontId::proportional(11.5),
theme::MUTED,
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
/// trims the picture, this keeps the whole picture and gives back part of the
/// rectangle.
fn contain_rect(image: Vec2, target: Rect) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 {
return target;
}
let scale = (target.width() / image.x).min(target.height() / image.y);
Rect::from_center_size(target.center(), image * scale)
}
/// UV rectangle that crops `image` to fill `target` without distorting it.
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
}
let image_aspect = image.x / image.y;
let target_aspect = target.x / target.y;
let (width, height) = if image_aspect > target_aspect {
// Too wide: keep the full height, trim the sides.
(target_aspect / image_aspect, 1.0)
} else {
(1.0, image_aspect / target_aspect)
};
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
}
/// Up to two letters, for a tile with no picture.
fn initials(name: &str) -> String {
name.split_whitespace()
.filter_map(|word| word.chars().next())
.filter(|character| character.is_alphanumeric())
.take(2)
.flat_map(char::to_uppercase)
.collect()
}
/// How many cards fit, and the gap that leaves them evenly spread.
pub fn columns(available: f32) -> usize {
(((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize)
.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");
assert_eq!(format_time(72.4), "1:12");
assert_eq!(format_time(3805.0), "1:03:25");
assert_eq!(format_time(f64::NAN), "--:--");
}
#[test]
fn sizes_use_binary_units() {
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024 * 1024), "1.0 MiB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024 / 2), "1.5 GiB");
}
#[test]
fn a_wide_image_is_cropped_at_the_sides() {
let uv = cover_uv(Vec2::new(200.0, 50.0), Vec2::new(100.0, 50.0));
assert!(uv.width() < 1.0);
assert!((uv.height() - 1.0).abs() < f32::EPSILON);
assert!((uv.center().x - 0.5).abs() < f32::EPSILON);
}
#[test]
fn a_tall_image_is_cropped_top_and_bottom() {
let uv = cover_uv(Vec2::new(50.0, 200.0), Vec2::new(100.0, 50.0));
assert!((uv.width() - 1.0).abs() < f32::EPSILON);
assert!(uv.height() < 1.0);
}
#[test]
fn a_degenerate_image_falls_back_to_the_whole_texture() {
let uv = cover_uv(Vec2::ZERO, Vec2::new(100.0, 50.0));
assert_eq!(
uv,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0))
);
}
#[test]
fn initials_take_two_letters() {
assert_eq!(initials("Cowboy Bebop"), "CB");
assert_eq!(initials("Akira"), "A");
assert_eq!(initials(""), "");
}
#[test]
fn column_count_never_reaches_zero() {
assert_eq!(columns(0.0), 1);
assert!(columns(2000.0) > 1);
}
}
+668
View File
@@ -0,0 +1,668 @@
//! The player page: the picture, filling the window, with controls over it.
//!
//! The video is one `painter.image` call — everything that made it a texture is
//! in [`crate::video`]. What is left here is the part a viewer notices: the
//! controls fade out of the way while something is playing and come back on the
//! first movement of the mouse, which is the behaviour every player has and the
//! only reason a full-bleed picture is usable at all.
//!
//! Two things are deliberately *not* subject to that fade. The skip-opening
//! button, which is only up for the ninety seconds it applies to and is useless
//! if you have to wake the controls to reach it; and the whole page while
//! nothing is playing — between two episodes there is no picture to keep clear
//! of, only a black screen that has to say what it is doing.
//!
//! Nothing in this module mutates. Like every other page it collects
//! [`Action`]s, so a click on "back" can change the page it was drawn on.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui::{self, transport::Neighbours};
/// How long the controls stay up after the pointer stops moving.
///
/// Long enough to reach for them again after glancing away, short enough that
/// they are gone by the time anyone is annoyed by them.
const IDLE_SECONDS: f64 = 2.5;
/// The pointer has to move at least this far, in points, to count as movement.
/// A trackpad reports jitter of a pixel or so while nobody is touching it, and
/// without a floor the controls never hide.
const MOVEMENT: f32 = 1.5;
/// Padding around the strip of controls along the bottom.
///
/// The height is *not* a constant. The controls are two rows of buttons whose
/// size follows the theme and the platform's text scaling, and a fixed strip
/// that guesses low puts the buttons past the bottom edge of the window — which
/// is what a 132 px constant did here.
const CHROME_PAD_X: f32 = 26.0;
const CHROME_PAD_BOTTOM: f32 = 18.0;
const CHROME_PAD_TOP: f32 = 16.0;
/// What to assume the controls are worth before they have been drawn once.
/// Only ever wrong for a single frame, and only on the first one.
const CHROME_HEIGHT_GUESS: f32 = 116.0;
/// Height of the title strip along the top.
const TITLE_HEIGHT: f32 = 68.0;
/// Gap between the controls and whatever floats above them.
const FLOAT_GAP: f32 = 12.0;
/// The skip button: how tall, and how much room the label gets either side.
/// The height doubles as its corner radius, which is what makes it a pill.
const SKIP_HEIGHT: f32 = 40.0;
const SKIP_PAD_X: f32 = 22.0;
/// How dark the scrim gets behind the controls, and how far above them it takes
/// to get there.
///
/// The fade lives in the empty picture *above* the controls rather than across
/// them, so every row of them — the seek bar and the timecodes included — sits
/// on the full wash. See [`scrim_bottom`].
const SCRIM_ALPHA: f32 = 170.0;
const SCRIM_FADE: f32 = 88.0;
/// State the page keeps between frames.
///
/// Held by the app and passed in by reference, because one thing here *is*
/// written while drawing: how tall the controls turned out. Everything the
/// viewer can see still comes from the app's state.
#[derive(Debug, Clone, Copy)]
pub struct Overlay {
/// When the pointer last moved, on egui's clock. `None` until the page has
/// seen a frame, which is what keeps the controls up on arrival — a viewer
/// who has just clicked "play" should see what the controls are before they
/// fade.
last_movement: Option<f64>,
/// Where the pointer was, to tell movement from jitter.
last_position: Option<egui::Pos2>,
/// How tall the controls were the last time they were drawn, padding
/// included. Measured rather than assumed — see [`CHROME_PAD_X`].
chrome_height: f32,
}
impl Default for Overlay {
fn default() -> Self {
Self {
last_movement: None,
last_position: None,
chrome_height: CHROME_HEIGHT_GUESS + CHROME_PAD_TOP + CHROME_PAD_BOTTOM,
}
}
}
/// Everything the page draws *over* the picture this frame.
///
/// Bundled because they arrive together and mean one thing between them — what
/// the controls are doing — and because passing them one at a time made the
/// page's entry point eight arguments long.
pub struct Chrome<'a> {
/// Whether the controls are up, and how tall they were last time. The one
/// piece of state drawing is allowed to write to.
pub overlay: &'a mut Overlay,
/// The end-of-episode card, when the app has decided there is one.
pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours,
}
/// What the end-of-episode card says, when there is one.
///
/// The app decides *whether* to show it and counts it down; this is only what
/// to draw. See [`crate::app::UpNext`].
pub struct UpNextCard {
/// Seconds until the next episode starts.
pub seconds: f64,
/// Which episode that is.
pub caption: String,
}
impl Overlay {
/// Fold this frame's pointer into the overlay's timer.
pub fn observe(&mut self, ctx: &egui::Context) {
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
let last_movement = *self.last_movement.get_or_insert(now);
let moved = match (pointer, self.last_position) {
(Some(current), Some(previous)) => current.distance(previous) > MOVEMENT,
(Some(_), None) => true,
(None, _) => false,
};
self.last_movement = Some(if moved { now } else { last_movement });
if let Some(current) = pointer {
self.last_position = Some(current);
}
}
/// Whether the controls should be on screen.
///
/// Always, while paused or still opening: a still picture with no controls
/// looks like a crash, and there is nothing to be distracted from.
fn visible(&self, ctx: &egui::Context, playback: &Playback) -> bool {
if playback.paused || !playback.loaded {
return true;
}
// A track or chapter menu is open. Hiding the controls would take the
// button the menu hangs off the screen with it, which closes the menu —
// so the viewer who stops to read a list of chapters loses the list.
if egui::Popup::is_any_open(ctx) {
return true;
}
let Some(last_movement) = self.last_movement else {
return true;
};
ctx.input(|input| input.time) - last_movement < IDLE_SECONDS
}
}
/// Draw the picture and its controls.
///
/// `playback` is `None` in the gap between one episode ending and the next
/// having opened — the page stays, because that is where the viewer means to
/// be, and `opening` is what it says while they wait.
pub fn show(
ui: &mut egui::Ui,
frame: &mut eframe::Frame,
playback: Option<&mut Playback>,
opening: Option<&str>,
chrome_state: Chrome<'_>,
actions: &mut Vec<Action>,
) {
let Chrome {
overlay,
up_next,
neighbours,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
if rect.width() < 1.0 || rect.height() < 1.0 {
return;
}
// Black rather than the app background: this is a cinema, and the letterbox
// bars mpv paints inside the texture have to be the same colour as what
// surrounds them.
ui.painter()
.rect_filled(rect, CornerRadius::ZERO, Color32::BLACK);
let Some(playback) = playback else {
between(ui, opening, rect, actions);
return;
};
let response = ui.allocate_rect(rect, Sense::click());
let picture = paint_video(ui, frame, playback, rect);
if !picture {
waiting(ui, playback, rect);
}
// Click anywhere on the picture to pause, which is the one control that
// should not require aiming at anything.
if response.clicked() {
actions.push(Action::Player(Command::TogglePause));
}
if response.double_clicked() {
actions.push(Action::ToggleFullscreen);
}
let visible = overlay.visible(&ctx, playback);
// Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys.
let float_above = if visible {
overlay.chrome_height + FLOAT_GAP
} else {
28.0
};
match up_next {
// The card supersedes the skip button: both live in the same corner,
// and both offer a way out of the same ninety seconds.
Some(card) => up_next_card(ui, &card, rect, float_above, actions),
None => skip(ui, playback, rect, float_above, actions),
}
if visible {
overlay.chrome_height = chrome(
ui,
playback,
neighbours,
rect,
overlay.chrome_height,
actions,
);
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is
// going to cause a frame while a film plays without the mouse moving.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
}
/// Render this frame's picture into the rectangle. Reports whether there was
/// one — a player whose first frame has not arrived has nothing to draw.
fn paint_video(
ui: &mut egui::Ui,
frame: &mut eframe::Frame,
playback: &mut Playback,
rect: Rect,
) -> bool {
let pixels_per_point = ui.ctx().pixels_per_point();
let Some(video) = playback.video.as_mut() else {
// mpv has a window of its own; there is nothing to composite here.
return false;
};
let size = [
(rect.width() * pixels_per_point).round() as i32,
(rect.height() * pixels_per_point).round() as i32,
];
let Some(texture) = video.texture(frame, size) else {
return false;
};
if !video.has_picture() {
return false;
}
ui.painter().image(
texture,
rect,
// The whole texture: mpv has already scaled the picture into it and
// letterboxed the remainder, so there is nothing to crop.
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
true
}
/// What fills the screen before the first frame.
fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
let painter = ui.painter();
painter.text(
rect.center() - Vec2::new(0.0, 18.0),
Align2::CENTER_CENTER,
&playback.target.title_name,
egui::FontId::proportional(20.0),
theme::TEXT,
);
painter.text(
rect.center() + Vec2::new(0.0, 10.0),
Align2::CENTER_CENTER,
if playback.is_embedded() {
"opening…"
} else {
"playing in mpv's own window"
},
egui::FontId::proportional(13.0),
theme::MUTED,
);
}
/// The screen between two episodes: one has ended and the next is opening.
///
/// It keeps a way out, because opening a file over a public link can take
/// seconds and a black screen with no controls is indistinguishable from a
/// hang.
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) {
ui.painter().text(
rect.center() - Vec2::new(0.0, 16.0),
Align2::CENTER_CENTER,
"Up next",
egui::FontId::proportional(14.0),
theme::MUTED,
);
ui.painter().text(
rect.center() + Vec2::new(0.0, 12.0),
Align2::CENTER_CENTER,
opening.unwrap_or("opening…"),
egui::FontId::proportional(20.0),
theme::TEXT,
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(Rect::from_min_size(
rect.min + Vec2::new(16.0, 14.0),
Vec2::new(240.0, 34.0),
))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer);
}
ui.add_space(8.0);
ui.add(egui::Spinner::new().size(14.0));
},
);
}
/// "Skip opening", when the playhead is inside a chapter that says it is one.
///
/// Bottom right, the corner every service puts it in, and drawn whether or not
/// the rest of the controls are up: an opening lasts ninety seconds, and a
/// button you have to wiggle the mouse to find is one you skip by hand instead.
fn skip(
ui: &mut egui::Ui,
playback: &Playback,
rect: Rect,
bottom_margin: f32,
actions: &mut Vec<Action>,
) {
let Some((label, to)) = playback.skippable() else {
return;
};
// Measured from the label rather than fixed: "Skip opening" and "Skip
// recap" are different lengths, and one constant leaves the shorter of them
// adrift in a box of its own whitespace, which is what made this read as a
// rectangle with some text in it instead of a button.
let font = egui::FontId::proportional(14.0);
let text_width = ui
.painter()
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
.size()
.x;
let size = Vec2::new((text_width + SKIP_PAD_X * 2.0).round(), SKIP_HEIGHT);
floating(ui, rect, size, bottom_margin, |ui| {
// Buttons given an explicit fill keep it in every state, so hover has
// to be answered by hand — and a control that does not answer the
// pointer at all is the other half of looking dead.
let hovered = ui.rect_contains_pointer(ui.max_rect());
let (fill, stroke) = if hovered {
(Color32::WHITE, Color32::WHITE)
} else {
(
Color32::from_black_alpha(170),
Color32::from_white_alpha(110),
)
};
let text = if hovered {
Color32::from_rgb(0x14, 0x14, 0x18)
} else {
Color32::WHITE
};
// Centred *and* justified. A button padded out by `min_size` puts its
// label wherever the surrounding layout aligns to, and `floating` aligns
// right — which left the label against the right edge with the box
// reaching past it. This fills the area and centres the label in it, so
// the padding lands evenly on both sides.
let clicked = ui
.centered_and_justified(|ui| {
ui.add(
egui::Button::new(egui::RichText::new(label).font(font).strong().color(text))
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
// A pill, like every other floating control here. The
// sharp 6 px corner was the one square thing on the
// picture.
.corner_radius(CornerRadius::same((SKIP_HEIGHT / 2.0) as u8)),
)
.on_hover_text(format!("Jump to {}", ui::format_time(to)))
.clicked()
})
.inner;
if clicked {
actions.push(Action::Player(Command::SeekTo(to)));
}
});
}
/// "Up next", counting down, with the two ways out of it.
///
/// The countdown itself belongs to the app — see [`crate::app::UpNext`] — so
/// this only draws what it was told and reports what was clicked. Both buttons
/// are real buttons rather than one button and a timer: a viewer who wants the
/// next episode *now* should not have to wait out a countdown that exists to
/// give them time to say no.
fn up_next_card(
ui: &mut egui::Ui,
card: &UpNextCard,
rect: Rect,
bottom_margin: f32,
actions: &mut Vec<Action>,
) {
let size = Vec2::new(330.0, 104.0);
floating(ui, rect, size, bottom_margin, |ui| {
egui::Frame::new()
.fill(Color32::from_black_alpha(215))
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(14, 12))
.show(ui, |ui| {
ui.set_width(size.x - 28.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Up next")
.size(12.0)
.strong()
.color(Color32::from_white_alpha(200)),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Rounded up, so a card that says "1s" is never
// followed by a second of nothing happening.
ui.label(
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
.size(12.0)
.color(theme::ACCENT),
);
});
});
ui.add(
egui::Label::new(
egui::RichText::new(&card.caption)
.size(14.0)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(6.0);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
.clicked()
{
actions.push(Action::WatchToEnd);
}
});
});
});
}
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
///
/// The inset matches the controls' own padding rather than being its own
/// number, so a floating button lines up with the volume slider under it
/// instead of sitting four pixels off it.
fn floating(
ui: &mut egui::Ui,
rect: Rect,
size: Vec2,
bottom_margin: f32,
add: impl FnOnce(&mut egui::Ui),
) {
let area = Rect::from_min_size(
egui::pos2(
rect.right() - CHROME_PAD_X - size.x,
rect.bottom() - bottom_margin - size.y,
),
size,
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(area)
.layout(egui::Layout::top_down(egui::Align::Max)),
add,
);
}
/// The controls: a title strip at the top, the transport at the bottom.
///
/// Returns how tall the bottom strip came out, padding included, so the next
/// frame can put its scrim behind it and float the skip button clear of it. It
/// is measured because it is not knowable: the transport is two rows of themed
/// buttons, and on a display that scales text they are taller than they are
/// here. Guessing low is what put the buttons through the bottom of the window.
fn chrome(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
rect: Rect,
previous_height: f32,
actions: &mut Vec<Action>,
) -> f32 {
let top = Rect::from_min_size(rect.min, Vec2::new(rect.width(), TITLE_HEIGHT));
scrim_top(ui, top);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui
.add(
egui::Button::new(egui::RichText::new("Library").size(13.0))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(96.0, 32.0)),
)
.on_hover_text("Keep playing, and go back to the library (Esc)")
.clicked()
{
actions.push(Action::LeavePlayer);
}
ui.add_space(14.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(&playback.target.title_name)
.size(18.0)
.strong()
.color(Color32::WHITE),
);
ui.label(
egui::RichText::new(playback.target.caption())
.size(13.0)
.color(Color32::from_white_alpha(190)),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(egui::RichText::new("⛶").size(15.0))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Fullscreen (F)")
.clicked()
{
actions.push(Action::ToggleFullscreen);
}
});
},
);
// Last frame's measurement places the scrim and the first row of controls;
// this frame's is returned for the next one. That is one frame of lag, and
// only while the window is being resized.
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.bottom() - previous_height),
Vec2::new(rect.width(), previous_height),
);
scrim_bottom(ui, strip);
// Anchored to the *bottom* of the window rather than laid out from the top
// of the strip: whatever the controls turn out to be worth, the last row of
// them ends a fixed distance above the edge instead of running off it.
let content = Rect::from_min_max(
egui::pos2(rect.left() + CHROME_PAD_X, strip.top() + CHROME_PAD_TOP),
egui::pos2(
rect.right() - CHROME_PAD_X,
rect.bottom() - CHROME_PAD_BOTTOM,
),
);
let measured = ui
.scope_builder(
egui::UiBuilder::new()
.max_rect(content)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui::transport::full(ui, playback, neighbours, actions);
ui.min_rect().height()
},
)
.inner;
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
}
/// The gradient behind the title strip, fading downwards out of the top edge.
///
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
/// primitive, and at this size the banding is invisible.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
const BANDS: usize = 10;
let painter = ui.painter();
let band = rect.height() / BANDS as f32;
for index in 0..BANDS {
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.top() + band * index as f32),
Vec2::new(rect.width(), band + 1.0),
);
painter.rect_filled(
strip,
CornerRadius::ZERO,
// Squared, so the fade is dark where the text is and gone well
// before the middle of the picture — a linear ramp over a strip
// this tall greys the whole top third of the frame.
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
);
}
}
/// The wash behind the controls: flat over the whole strip, fading in above it.
///
/// A gradient that starts fading only where the controls start puts the top of
/// them — the seek bar, the position and the chapter name — over what is very
/// nearly bare picture, and over a bright frame that text disappears. So the
/// strip itself is one flat wash, and the ramp happens in the empty picture
/// above it, where there is nothing to obscure.
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
const BANDS: usize = 12;
let painter = ui.painter();
painter.rect_filled(
strip,
CornerRadius::ZERO,
Color32::from_black_alpha(SCRIM_ALPHA as u8),
);
let band = SCRIM_FADE / BANDS as f32;
for index in 0..BANDS {
let fraction = (index + 1) as f32 / BANDS as f32;
let rect = Rect::from_min_size(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
Vec2::new(strip.width(), band + 1.0),
);
painter.rect_filled(
rect,
CornerRadius::ZERO,
// Squared again: the wash should meet the strip at full strength
// and be gone within a finger's width of picture above it.
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! The shares page: which links this app knows, and how to add one.
//!
//! The URL fragment of a Proton share link **is** its decryption password, so
//! this page never prints a URL back — `shares.json` holds only the id, the name
//! and the token, and the secrets live in the OS credential store. The form
//! below is the only place a link is ever visible, and only while it is being
//! typed.
use pstr_core::Share;
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::{Action, ShareForm};
use crate::theme;
use crate::ui;
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
form: &mut ShareForm,
settings: &mut MetadataConfig,
autoplay: bool,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui::section(ui, "Shares");
if shares.is_empty() {
ui.label(ui::muted("No shares yet."));
}
for share in shares {
share_row(ui, share, actions);
}
ui.add_space(22.0);
add_form(ui, form, actions);
ui.add_space(28.0);
playback_form(ui, autoplay, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
});
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
/// on sends the titles in someone's library to a third party, which is a thing
/// they can only agree to if they are told — so it is stated where the switch
/// is, before the switch, in the same size as everything else.
/// The handful of playback settings that are not per-file.
///
/// Volume, audio language and subtitle language are all set from the player
/// itself, where a viewer can hear the result — only autoplay has no natural
/// home there, because by the time it matters the episode is over.
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
let mut enabled = autoplay;
if ui
.checkbox(&mut enabled, "Play the next episode automatically")
.on_hover_text("Only when an episode reaches its end, never after a failure")
.changed()
{
actions.push(Action::SetAutoplay(enabled));
}
ui.label(ui::muted(
"Volume, audio language and subtitles are set from the player, and remembered \
from there.",
));
});
}
fn metadata_form(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(10.0);
let mut changed = false;
let mut enabled = settings.enabled;
if ui
.checkbox(&mut enabled, "Look up posters and descriptions")
.changed()
{
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(10.0);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(10.0);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(6.0);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
});
}
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(&share.name).strong());
let detail = if share.has_custom_password {
format!("{} · custom password", share.id)
} else {
share.id.clone()
};
ui.label(ui::muted(detail));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Remove").fill(theme::CARD_HOVER))
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
.clicked()
{
actions.push(Action::RemoveShare(share.id.clone()));
}
if ui.button("Crawl").clicked() {
actions.push(Action::Crawl(Some(share.id.clone())));
}
});
});
});
ui.add_space(6.0);
}
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui::section(ui, "Add a share");
ui.label(ui::muted(
"Paste the whole link, including everything after the # — that part is the key that \
decrypts it, and it is stored in your system keyring rather than on disk.",
));
ui.add_space(10.0);
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Name"));
ui.add(
egui::TextEdit::singleline(&mut form.name)
.hint_text("Anime")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.label(ui::muted("Link"));
ui.add(
egui::TextEdit::singleline(&mut form.url)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.checkbox(&mut form.has_password, "The link asks for a password");
if form.has_password {
ui.add_space(6.0);
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
.hint_text("Link password")
.desired_width(f32::INFINITY),
);
}
ui.add_space(12.0);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
&& (!form.has_password || !form.password.is_empty());
ui.horizontal(|ui| {
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
.inner
.clicked()
{
actions.push(Action::AddShare {
name: form.name.trim().to_string(),
url: form.url.trim().to_string(),
password: form
.has_password
.then(|| form.password.clone())
.filter(|password| !password.is_empty()),
});
}
if !ready {
ui.label(ui::muted("A name and a link are needed."));
}
});
});
}
+331
View File
@@ -0,0 +1,331 @@
//! One title: the still, what it is, and every episode under it.
use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
use pstr_core::metadata::TitleMetadata;
use crate::app::{Action, Page};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::{self, Art, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
actions: &mut Vec<Action>,
) {
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
actions.push(Action::Goto(Page::Library));
return;
};
if ui.button("Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(10.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, actions);
ui.add_space(18.0);
for (index, season) in title.seasons.iter().enumerate() {
let open = index == 0 || title.seasons.len() == 1;
egui::CollapsingHeader::new(
egui::RichText::new(format!(
"{} · {}",
season.label(),
ui::library::plural(season.episodes.len(), "episode")
))
.size(15.0)
.strong(),
)
.id_salt(("season", index))
.default_open(open)
.show(ui, |ui| {
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, actions);
}
});
ui.add_space(6.0);
}
});
}
/// The still, the name and the one button that matters.
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
// Cloned out of the borrow: `art` is held mutably for the card below, and
// the description is drawn beside it.
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
ui.horizontal_top(|ui| {
ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
},
);
ui.add_space(18.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new(&title.name).size(26.0).strong());
// The provider's name for it, when it is not the one the files use.
// Worth showing rather than replacing the filename's: a viewer
// should be able to tell what the match actually matched.
if let Some(found) = &found
&& found.name != title.name
{
ui.label(ui::muted(format!("also known as {}", found.name)));
}
ui.add_space(4.0);
ui.label(ui::muted(meta_line(title, found.as_ref())));
ui.add_space(14.0);
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let resuming = episode.resume_at().is_some();
let label = match (resuming, episode.numbering()) {
(true, Some(numbering)) => format!("Resume {numbering}"),
(true, None) => "Resume".to_string(),
(false, Some(numbering)) if title.kind == TitleKind::Series => {
format!("Play {numbering}")
}
_ => "Play".to_string(),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
if resuming && let Some(at) = episode.resume_at() {
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
if ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
}
}
});
if let Some(found) = &found {
ui.add_space(14.0);
description(ui, found);
}
});
});
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() {
ui.label(ui::muted(found.genres.join(" · ")));
ui.add_space(6.0);
}
if let Some(overview) = &found.overview {
ui.add(
egui::Label::new(egui::RichText::new(overview).size(13.0).color(theme::TEXT)).wrap(),
);
}
if let Some(url) = &found.url {
ui.add_space(8.0);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
}
/// The grey line under the name: what the library knows, plus what the provider
/// added. The library's own counts come first — they describe the files that are
/// actually there, which is what a viewer is deciding about.
fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(
match title.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
}
.to_string(),
);
if let Some(year) = title.year {
parts.push(year.to_string());
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
}
parts.push(ui::library::plural(title.episode_count(), "episode"));
}
let watched = title.watched_count();
if watched > 0 {
parts.push(format!("{watched} watched"));
}
if let Some(found) = found {
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
}
// Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
// says the share is missing half a season.
if let Some(total) = found.episodes
&& title.kind == TitleKind::Series
&& total as usize != title.episode_count()
{
parts.push(format!("{total} on {}", found.provider.label()));
}
}
parts.join(" · ")
}
/// One line per file: click anywhere on it to play.
fn episode_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
season: &Season,
episode: &Episode,
actions: &mut Vec<Action>,
) {
// Cloned out before `ui` borrows: the row draws while `art` is held.
let found = art.episode(&title.key, episode).map(|found| {
(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
)
});
let watched = episode.is_watched();
egui::Frame::new()
.fill(if watched {
theme::BACKGROUND
} else {
theme::CARD
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
// An explicit target rather than a clickable row: the row also
// carries a checkbox, and a click that means "seen" must never
// be one that starts a 1.4 GiB stream instead.
if ui
.add(
egui::Button::new(egui::RichText::new("▶").size(13.0))
.fill(theme::CARD_HOVER),
)
.on_hover_text("Play")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
ui.add_sized(
[66.0, 18.0],
egui::Label::new(
egui::RichText::new(numbering)
.monospace()
.color(theme::MUTED),
),
);
// The provider's name for the episode, when there is one:
// "The Immortal Legion" reads as an episode, and
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched {
theme::MUTED
} else {
theme::TEXT
});
let hover = match &found {
// The synopsis, where the provider has one — AniList does
// not, and the filename is worth showing either way.
Some((_, Some(overview), _)) => {
format!("{}\n\n{overview}", episode.node.name)
}
_ => episode.node.name.clone(),
};
ui.add(egui::Label::new(label).truncate())
.on_hover_text(hover);
if let Some((_, _, Some(air_date))) = &found {
ui.label(ui::muted(air_date.clone()));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut checked = watched;
if ui
.checkbox(&mut checked, "")
.on_hover_text(if watched {
"Mark unwatched"
} else {
"Mark watched"
})
.changed()
{
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: checked,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
match episode.node.size {
// A file the share says is empty is one that will not
// play — an upload that never finished, usually. Worth
// saying so on the row rather than only when a click on
// it comes back with "has no content".
Some(0) => {
ui.label(
egui::RichText::new("empty")
.size(12.0)
.color(theme::DANGER),
)
.on_hover_text(
"The share reports no content for this file; it cannot be played.",
);
}
Some(size) => {
ui.label(ui::muted(ui::format_size(size)));
}
None => {}
}
if let Some(at) = episode.resume_at() {
ui.label(
egui::RichText::new(format!("resume {}", ui::format_time(at)))
.size(12.0)
.color(theme::ACCENT),
);
}
});
});
});
ui.add_space(4.0);
}
+474
View File
@@ -0,0 +1,474 @@
//! The controls for whatever is playing, in two densities.
//!
//! [`full`] is the overlay over the picture: everything, laid out to be aimed at
//! from a sofa — a wide seek bar with the file's chapters marked on it, a
//! centred transport cluster, and the pickers off to the right. [`mini`] is the
//! strip along the bottom of the library, where the picture is *not*: it keeps
//! only what someone browsing needs, plus the one control that page has to have
//! — the way back to the video.
//!
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
//! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Vec2};
use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
const SEEK_HIT_HEIGHT: f32 = 18.0;
/// How wide the volume slider draws. Wide enough to land on a value, narrow
/// enough that it does not read as a second seek bar.
const VOLUME_WIDTH: f32 = 90.0;
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
pub previous: bool,
pub next: bool,
}
/// The overlay's controls: the bottom half of the player page.
pub fn full(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
times(ui, playback, true);
ui.add_space(6.0);
ui.horizontal(|ui| {
// Left cluster: everything that moves the playhead, in the order a
// hand reaches for it.
step_button(
ui,
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
ui.add_space(12.0);
if playback.seeking || !playback.loaded {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded {
"seeking"
} else {
"buffering"
}));
}
// Right cluster: everything that is a choice rather than a move.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(8.0);
chapter_menu(ui, playback, actions);
ui.add_space(8.0);
volume(ui, playback, actions);
});
});
}
/// The strip under the library: what is playing, and back to it.
pub fn mini(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
// The way back to the picture. First, and a real button rather than a
// label, because leaving the player page with Esc is otherwise a
// one-way trip: playback carries on with nowhere to watch it.
if ui
.add(
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
.fill(theme::CARD_HOVER)
.corner_radius(CornerRadius::same(8)),
)
.on_hover_text("Show the picture again")
.clicked()
{
actions.push(Action::Goto(Page::Player));
}
ui.add_space(10.0);
ui.vertical(|ui| {
// Back to what is playing: after ten minutes of browsing, the bar
// is the only thing that still knows which title this came from.
if ui
.add(
egui::Label::new(
egui::RichText::new(&playback.target.title_name)
.size(14.0)
.strong(),
)
.sense(Sense::click()),
)
.on_hover_text("Show this title")
.clicked()
{
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
}
ui.label(ui::muted(playback.target.caption()));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(6.0);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(8.0);
track_menus(ui, playback, actions);
ui.add_space(6.0);
volume(ui, playback, actions);
if playback.seeking || !playback.loaded {
ui.add_space(6.0);
ui.add(egui::Spinner::new().size(14.0));
}
});
});
ui.add_space(4.0);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
}
/// The one button whose position should never move.
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
let symbol = if playback.paused { "▶" } else { "⏸" };
if ui
.add(
egui::Button::new(egui::RichText::new(symbol).size(size * 0.42))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.min_size(Vec2::splat(size)),
)
.on_hover_text(if playback.paused {
"Play (Space)"
} else {
"Pause (Space)"
})
.clicked()
{
actions.push(Action::Player(Command::TogglePause));
}
}
fn skip_button(
ui: &mut egui::Ui,
label: &str,
hover: &str,
seconds: f64,
actions: &mut Vec<Action>,
) {
if ui
.add(egui::Button::new(label).min_size(Vec2::new(52.0, 30.0)))
.on_hover_text(hover)
.clicked()
{
actions.push(Action::Player(Command::SeekBy(seconds)));
}
}
/// Previous/next episode. Drawn even at the ends of a title, disabled: a
/// control that comes and goes is harder to find than one that is greyed.
fn step_button(
ui: &mut egui::Ui,
label: &str,
hover: &str,
enabled: bool,
actions: &mut Vec<Action>,
push: impl FnOnce(&mut Vec<Action>),
) {
let response = ui.add_enabled(
enabled,
egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)),
);
if response.on_hover_text(hover).clicked() {
push(actions);
}
}
/// Position and duration, at the ends of the seek bar they belong to.
///
/// `over_picture` picks the ink. Muted grey is right on the library's own dark
/// surface, and unreadable over a bright frame — a pale grey at 12 px against
/// a sunlit shot is gone whatever the scrim does, so over the picture this goes
/// to near-white and a point larger.
fn times(ui: &mut egui::Ui, playback: &Playback, over_picture: bool) {
let (size, ink, dim) = if over_picture {
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
} else {
(12.0, theme::MUTED, theme::MUTED)
};
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
ui.horizontal(|ui| {
ui.label(time(ui::format_time(playback.position)));
if let Some(chapter) = playback.chapter().filter(|_| playback.chapters.len() > 1) {
ui.label(
egui::RichText::new(format!("· {}", chapter.label()))
.size(size)
.color(dim),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
match playback.duration {
Some(duration) => ui.label(time(ui::format_time(duration))),
None => ui.label(time("—:—".to_owned())),
};
});
});
}
/// Mute, and how loud.
///
/// Drawn into a right-to-left layout, so the slider is added first and ends up
/// to the *right* of the speaker it belongs to.
fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
// A muted player shows an empty slider rather than the level it will come
// back to: "muted" and "turned down" look the same on a bar, and only one
// of them is undone by clicking the speaker.
let mut value = if playback.muted { 0.0 } else { playback.volume };
let slider = ui
.scope(|ui| {
ui.spacing_mut().slider_width = VOLUME_WIDTH;
ui.add(
egui::Slider::new(&mut value, 0.0..=MAX_VOLUME)
.show_value(false)
.trailing_fill(true),
)
})
.inner;
if slider.changed() {
// Committed on release, not on every frame of the drag: this is a file
// write, and a slider dragged across the bar would be one per frame.
actions.push(Action::SetVolume {
volume: value,
commit: !slider.dragged(),
});
}
if slider.drag_stopped() {
actions.push(Action::SetVolume {
volume: value,
commit: true,
});
}
let icon = if playback.muted || playback.volume <= 0.0 {
"🔇"
} else {
"🔊"
};
if ui
.button(icon)
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
actions.push(Action::ToggleMute);
}
}
/// The audio and subtitle pickers.
///
/// Each is shown only when there is a choice to make: one audio track is not a
/// menu, and a file with no subtitles should not offer a subtitle button that
/// opens onto nothing. Added subtitles-first so that, in this right-to-left
/// layout, audio reads to the left of subtitles.
fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let subtitles: Vec<&Track> = playback.tracks_of(TrackKind::Subtitle).collect();
if !subtitles.is_empty() {
let selected = playback.selected_track(TrackKind::Subtitle);
let label = match selected {
Some(track) => format!("Subs · {}", short(track)),
None => "Subs · Off".to_string(),
};
ui.menu_button(label, |ui| {
// "Off" first, and always: it is the entry most often wanted, and
// it is the only one that is not in the file.
if ui.selectable_label(selected.is_none(), "Off").clicked() {
actions.push(Action::SelectTrack {
kind: TrackKind::Subtitle,
id: None,
});
ui.close();
}
ui.separator();
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
})
.response
.on_hover_text("Subtitle track");
}
let audio: Vec<&Track> = playback.tracks_of(TrackKind::Audio).collect();
if audio.len() > 1 {
let label = match playback.selected_track(TrackKind::Audio) {
Some(track) => format!("Audio · {}", short(track)),
None => "Audio · Off".to_string(),
};
ui.menu_button(label, |ui| {
track_options(ui, &audio, TrackKind::Audio, actions);
})
.response
.on_hover_text("Audio track");
}
}
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() {
actions.push(Action::SelectTrack {
kind,
id: Some(track.id),
});
ui.close();
}
}
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
if playback.chapters.len() < 2 {
return;
}
let current = playback.chapter().map(|chapter| chapter.index);
ui.menu_button("Chapters", |ui| {
for chapter in &playback.chapters {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
if ui
.selectable_label(current == Some(chapter.index), label)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
ui.close();
}
}
})
.response
.on_hover_text("Chapters");
}
/// A track in as few words as fit on a button: what distinguishes it from the
/// others, which is nearly always its language.
fn short(track: &Track) -> String {
track
.language
.as_deref()
.map(pstr_player::language_name)
.or_else(|| track.title.clone())
.unwrap_or_else(|| format!("Track {}", track.id))
}
/// A bar that shows where playback is, marks the chapters, and seeks where it
/// is clicked.
fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, thickness: f32) {
let width = ui.available_width();
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
let hovered = response.hovered() || response.dragged();
let track = Rect::from_center_size(
rect.center(),
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
);
let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
if let Some(progress) = playback.progress() {
let mut played = track;
played.set_width(track.width() * progress);
painter.rect_filled(played, CornerRadius::same(3), theme::ACCENT);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },
Color32::WHITE,
);
}
// Seeking needs a duration: without one there is nothing for a fraction of
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
return;
};
// Chapter marks, over the bar rather than under it: an opening is two
// minutes of a twenty-four minute file, and the point of the mark is to be
// able to aim just past it.
for chapter in playback.chapters.iter().skip(1) {
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
let x = track.left() + track.width() * fraction;
painter.rect_filled(
Rect::from_min_size(
egui::pos2(x - 1.0, track.top() - 1.0),
Vec2::new(2.0, track.height() + 2.0),
),
CornerRadius::ZERO,
Color32::from_black_alpha(180),
);
}
// On release, not on every frame of the drag: each seek cancels outstanding
// read-ahead and refetches, so a dragged bar that seeks continuously spends
// the whole drag throwing away blocks it just paid for.
if let Some(position) = response.interact_pointer_pos()
&& (response.clicked() || response.drag_stopped())
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
}
if response.hovered()
&& let Some(position) = response.hover_pos()
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
let at = fraction * duration;
// The chapter under the pointer, when there is one: "42:10 · OP" tells
// a viewer what they are about to land in, which is the whole reason to
// aim at a particular part of the bar.
let hint = match pstr_player::chapter_at(&playback.chapters, at) {
Some(index) => format!(
"{} · {}",
ui::format_time(at),
playback.chapters[index].label()
),
None => ui::format_time(at),
};
response.clone().on_hover_text_at_pointer(hint);
}
}
+274
View File
@@ -0,0 +1,274 @@
//! The picture, inside this window.
//!
//! mpv draws into a framebuffer we own; the colour texture behind it is handed
//! to egui and painted like any other image. That indirection — rather than
//! letting mpv draw straight into the window's back buffer — is what lets the
//! video sit in a *rectangle*: `mpv_render_context_render` always fills the
//! framebuffer it is given from its origin, with no way to offset it, so a
//! direct draw could only ever be full-window.
//!
//! ```text
//! App::ui (GL current)
//! │ size in physical pixels
//! ▼
//! VideoSurface::texture ──▶ mpv render ──▶ FBO ──▶ colour texture
//! │
//! egui::TextureId ◀───┘ painter.image(…)
//! ```
//!
//! Three things here are less obvious than they look:
//!
//! * **The texture is registered once and resized in place.**
//! [`eframe::Frame::register_native_glow_texture`] takes ownership of the
//! texture and there is no matching unregister, so registering a fresh one per
//! resize would leak one texture per drag of the window edge. Reallocating the
//! *same* GL name with `tex_image_2d` keeps the `TextureId` valid and leaks
//! nothing.
//! * **A frame is rendered only when mpv says there is one.** Otherwise the
//! texture from last frame is still correct, and egui repaints far more often
//! than a 24 fps film changes.
//! * **mpv's GL state churn is safe here by contract, not by luck.** mpv
//! restores everything except the viewport, the scissor box, the blend
//! function and the clear colour — all four of which `egui_glow` sets itself
//! at the start of every paint.
use std::sync::Arc;
use eframe::glow::{self, HasContext as _};
use pstr_player::{Player, VideoRenderer};
/// Smallest framebuffer worth allocating. A window dragged to nothing still has
/// to leave mpv something valid to render into.
const MIN_EDGE: i32 = 16;
/// Largest, in either direction. A 4K display at 200% scaling asks for 7680 px
/// of framebuffer for a rectangle nothing can resolve; this is the ceiling that
/// keeps a stray `pixels_per_point` from asking the driver for half a gigabyte.
const MAX_EDGE: i32 = 4096;
/// mpv's render context plus the framebuffer it draws into.
///
/// Not `Send`: every method here needs the OpenGL context that eframe makes
/// current around [`eframe::App::ui`], so this lives on the UI thread while the
/// [`Player`] it renders is shared with the thread polling mpv's events.
pub struct VideoSurface {
gl: Arc<glow::Context>,
renderer: VideoRenderer,
/// Owned by eframe once registered — deliberately never deleted here.
texture: Option<glow::Texture>,
id: Option<egui::TextureId>,
fbo: Option<glow::Framebuffer>,
size: [i32; 2],
/// Set until the first frame actually lands, so the caller can say
/// "buffering" rather than show a black rectangle it cannot explain.
painted: bool,
}
impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context.
///
/// `ctx` is woken whenever mpv has a new frame; without that the picture
/// would only advance when the viewer moved the mouse.
///
/// # Safety
///
/// An OpenGL context must be current on the calling thread — inside
/// [`eframe::App::ui`] it is — and this value must be dropped on that same
/// thread with the context still current.
pub unsafe fn new(
player: Arc<Player>,
gl: Arc<glow::Context>,
ctx: egui::Context,
) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(move || ctx.request_repaint());
Ok(Self {
gl,
renderer,
texture: None,
id: None,
fbo: None,
size: [0, 0],
painted: false,
})
}
/// Whether a frame has ever reached the screen.
pub fn has_picture(&self) -> bool {
self.painted
}
/// Render at `size` physical pixels if there is anything new, and give back
/// the texture to paint.
///
/// `None` means there is nothing to draw yet — the framebuffer could not be
/// built, or the file has no video track.
pub fn texture(
&mut self,
frame: &mut eframe::Frame,
size: [i32; 2],
) -> Option<egui::TextureId> {
let size = [
size[0].clamp(MIN_EDGE, MAX_EDGE),
size[1].clamp(MIN_EDGE, MAX_EDGE),
];
// A resize invalidates the picture as well as the storage: the old
// contents are gone, so redraw even if mpv has no new frame.
let resized = self.ensure(frame, size)?;
if !resized && !self.renderer.has_new_frame() {
return self.id;
}
let fbo = self.fbo?;
// SAFETY: the context is current for the whole of `App::ui`, and `fbo`
// was checked complete when it was built.
unsafe {
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
// No flip: mpv writes the top of the picture into the first row of
// the texture, which is where egui's V axis starts. `FLIP_Y` is for
// drawing into the *default* framebuffer, and here it would stand
// the picture on its head. See `VideoRenderer::render`.
let rendered = self
.renderer
.render(fbo.0.get() as i32, size[0], size[1], false);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if let Err(error) = rendered {
tracing::warn!("render a video frame: {error}");
return self.id;
}
}
self.painted = true;
self.id
}
/// Make sure there is a framebuffer of `size`, reporting whether it changed.
///
/// `None` if one could not be built at all, which is the only failure that
/// makes the surface useless rather than merely late.
fn ensure(&mut self, frame: &mut eframe::Frame, size: [i32; 2]) -> Option<bool> {
if self.texture.is_none() {
// SAFETY: the context is current, and every object created here is
// either handed to eframe (the texture) or deleted in `Drop` (the
// framebuffer).
unsafe {
let texture = self
.gl
.create_texture()
.inspect_err(|error| tracing::error!("create the video texture: {error}"))
.ok()?;
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
// Linear, and clamped: the video is scaled to whatever rectangle
// the window leaves it, and a repeating wrap would fringe the
// letterbox edges with the opposite side of the picture.
for (parameter, value) in [
(glow::TEXTURE_MIN_FILTER, glow::LINEAR),
(glow::TEXTURE_MAG_FILTER, glow::LINEAR),
(glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE),
(glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE),
] {
self.gl
.tex_parameter_i32(glow::TEXTURE_2D, parameter, value as i32);
}
self.gl.bind_texture(glow::TEXTURE_2D, None);
let fbo = self
.gl
.create_framebuffer()
.inspect_err(|error| tracing::error!("create the video framebuffer: {error}"))
.ok()?;
self.texture = Some(texture);
self.fbo = Some(fbo);
}
// Once: the `TextureId` outlives every resize, because the resize
// reallocates this same GL name rather than making a new one.
self.id = Some(frame.register_native_glow_texture(self.texture?));
}
if self.size == size {
return Some(false);
}
let (texture, fbo) = (self.texture?, self.fbo?);
// SAFETY: both objects were created above on this context, which is
// still current.
unsafe {
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA8 as i32,
size[0],
size[1],
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(None),
);
self.gl.bind_texture(glow::TEXTURE_2D, None);
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER,
glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D,
Some(texture),
0,
);
let status = self.gl.check_framebuffer_status(glow::FRAMEBUFFER);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if status != glow::FRAMEBUFFER_COMPLETE {
tracing::error!("video framebuffer is incomplete: 0x{status:x}");
return None;
}
}
self.size = size;
Some(true)
}
}
impl Drop for VideoSurface {
/// Runs on the UI thread with the context current — see the note on
/// [`VideoRenderer`]'s destructor, which has the same requirement and is
/// the reason this type is dropped from the draw loop rather than wherever
/// playback happens to end.
fn drop(&mut self) {
if let Some(fbo) = self.fbo.take() {
// SAFETY: created on this context, deleted once.
unsafe { self.gl.delete_framebuffer(fbo) };
}
// The texture is deliberately not deleted: `register_native_glow_texture`
// took ownership of it, and eframe frees it with the painter.
}
}
/// The framebuffer name a `GL_DRAW_FRAMEBUFFER_BINDING` query returned, as
/// something `bind_framebuffer` accepts. Zero is the default framebuffer, which
/// glow spells `None`.
fn restore(binding: i32) -> Option<glow::Framebuffer> {
std::num::NonZeroU32::new(binding as u32).map(glow::NativeFramebuffer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zero_binding_restores_the_default_framebuffer() {
assert!(restore(0).is_none());
assert!(restore(7).is_some());
}
}