//! 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, StreamHandle, 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, serde::Serialize, serde::Deserialize)] 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, pub number: Option, /// 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, /// Where to resume, when the viewer had been here before. pub resume_at: Option, /// Per-show track choices, filled by the app just before opening. pub track_prefs: Option>, } 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) -> 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, track_prefs: None, } } /// The line under the title while this plays: the numbering, and the /// episode's name when a provider has given one. /// How to name this file to someone not looking at its title page: /// the title and the episode, or the file name when there is no title. pub fn caption_or_name(&self) -> String { match (self.title_name.is_empty(), self.caption()) { (false, caption) if !caption.is_empty() => format!("{} — {caption}", self.title_name), (false, _) => self.title_name.clone(), (true, _) => self.name.clone(), } } 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)] 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), /// Move subtitles, as a percentage of the window's height. See /// [`Playback::lift_subtitles`]. SetSubtitlePosition(f64), /// 1.0 is normal speed. SetSpeed(f64), /// Save the current frame to this file. Screenshot(std::path::PathBuf), 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, /// Set by the player thread once it has stopped mpv and written the final /// watch position. finished: Arc, /// Where mpv's frames land, when the picture is inside this window. `None` /// means mpv has a window of its own. pub video: Option, pub target: PlaybackTarget, pub position: f64, pub duration: Option, pub paused: bool, /// 0–100, as mpv has it. pub volume: f64, pub muted: bool, /// Playback speed, 1.0 being normal. pub speed: f64, /// Where subtitles were last put, so they are only moved when that changes /// rather than once a frame. subtitle_position: f64, /// What the file contains. Empty until mpv has demuxed it. pub tracks: Vec, /// The file's chapters, in order. Empty for a file muxed without them. pub chapters: Vec, /// 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, /// 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, /// 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, /// The end of what mpv has read ahead, in seconds from the start. pub buffered: Option, /// Playback is held waiting for data. pub buffering: 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>, ) -> Result { let prefs = engine.playback_prefs(); let (player, video) = build_player(engine, &target, &prefs, gl)?; let (commands, orders) = channel::(); 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, subtitle_position: 100.0, speed: prefs.speed, 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, buffered: None, buffering: false, target, }) } /// Keep subtitles clear of `covered` points at the bottom of a picture /// `height` points tall — the controls, while they are up. /// /// Without it the line being read sits under the seek bar for as long as /// the mouse is moving, which is exactly when someone is looking at the /// screen. pub fn lift_subtitles(&mut self, covered: f32, height: f32) { let position = if covered > 0.0 && height > 0.0 { (100.0 * (1.0 - f64::from(covered / height))).round() } else { 100.0 }; if position != self.subtitle_position { self.subtitle_position = position; self.send(Command::SetSubtitlePosition(position)); } } /// 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 { 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 chapters say where the credits start. Most files that are /// not anime have no chapters at all. pub fn knows_credits(&self) -> bool { self.credits_at.is_some() } /// Seconds of the file left to play, once its length is known. pub fn remaining(&self) -> Option { self.duration .map(|duration| (duration - self.position).max(0.0)) } /// 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, PlayerEvent::Buffered(end) => self.buffered = Some(*end), PlayerEvent::Buffering(held) => self.buffering = *held, _ => {} } } /// 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 { 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>, ) -> Result<(Arc, Option), 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, hardware_decoding: engine.desktop_prefs().hardware_decoding, ..from_prefs(prefs, target.track_prefs.as_deref()) }; 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), { let engine = engine.clone(); move || engine.wake() }) } { 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, hardware_decoding: engine.desktop_prefs().hardware_decoding, ..from_prefs(prefs, target.track_prefs.as_deref()) }; 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, show: Option<&pstr_core::catalog::TitleTrackPrefs>, ) -> PlayerConfig { PlayerConfig { volume: prefs.volume, muted: prefs.muted, audio_language: show .and_then(|p| p.audio_language.clone()) .or_else(|| prefs.audio_language.clone()), subtitle_language: show .and_then(|p| p.subtitle_language.clone()) .or_else(|| prefs.subtitle_language.clone()), subtitles: show.map(|p| p.subtitles).unwrap_or(prefs.subtitles), // Carried from file to file, like the volume: somebody who watches at // 1.25 watches the next episode at 1.25 too. options: vec![("speed".into(), format!("{:.2}", prefs.speed))], ..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, orders: std::sync::mpsc::Receiver, finished: Arc, ) { let opened = match target.resume_at { Some(at) => player.play_from(stream, at), None => player.play(stream), }; let handle = match opened { 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; } }; // Opened while mpv opens the video, and added once it has: an external // track belongs to the file loaded at the time. let mut sidecars = Some(engine.runtime().spawn({ let engine = engine.clone(); let target = target.clone(); async move { engine.open_sidecars(&target).await } })); let mut subtitles: Vec = Vec::new(); let mut position = target.resume_at.unwrap_or(0.0); let mut duration: Option = None; let mut watched = false; 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(); if let Some(opening) = sidecars.take() { let opened = engine.runtime().block_on(opening).unwrap_or_default(); subtitles = add_sidecars(&engine, &target, &player, opened); } // The first moment there is a track list to read: mpv has // demuxed the file and made its own default selection. Put the // title's own choice over it before the menus are told, so they // open showing what is actually playing. if let Some(show) = target.track_prefs.as_deref() { restore_tracks(&player, show); } emit_tracks(&engine, id, &player); } 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(subtitles); 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::SetSubtitlePosition(percent) => player.set_subtitle_position(percent), Command::SetSpeed(speed) => player.set_speed(speed), Command::Screenshot(path) => player.screenshot(&path), 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, ) -> 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(()) } /// Put back the tracks this title was last watched with. /// /// `alang`/`slang` already steered mpv's pick by language. This is for what a /// language cannot say — which of two English subtitle tracks, or an audio track /// with a name and no tag — so only a remembered *title* acts here; a title with /// nothing but a language keeps mpv's selection, which honours it already. fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) { let tracks = player.tracks(); let wanted = [ ( TrackKind::Audio, show.audio_language.as_deref(), show.audio_title.as_deref(), true, ), ( TrackKind::Subtitle, show.subtitle_language.as_deref(), show.subtitle_title.as_deref(), show.subtitles, ), ]; for (kind, language, title, enabled) in wanted { if !enabled || title.is_none() { continue; } let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else { continue; }; let playing = tracks .iter() .any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected); if !playing && let Err(error) = player.select_track(kind, Some(track)) { tracing::warn!("restore {} track {track}: {error}", kind.label()); } } } /// Hand mpv the subtitle files paired with the video it has just loaded. /// /// One is turned on only when subtitles are on and the video has none of its /// own playing — a file's own track was chosen by name and language already, /// and an external one is the fallback, not an override. fn add_sidecars( engine: &Engine, target: &PlaybackTarget, player: &Player, opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>, ) -> Vec { let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref()); let playing = player .tracks() .iter() .any(|track| track.kind == TrackKind::Subtitle && track.selected); let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip(); let select = (config.subtitles && !playing) .then(|| { pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref()) }) .flatten(); sidecars .iter() .zip(streams) .enumerate() .filter_map(|(index, (sidecar, stream))| { let title = sidecar.title.as_deref().unwrap_or("External"); player .add_subtitle( stream, title, sidecar.language.as_deref(), select == Some(index), ) .inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name)) .ok() }) .collect() } /// 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, watched: bool, ) { engine.save_watch_state( target.share_id.clone(), target.link_id.clone(), watch_state(position, duration, watched), ); }