mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-05 17:12:10 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user