From 093b13f03ead5f201a50356b6e8f3c473d0989fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis?= Date: Fri, 22 May 2026 15:46:17 +0300 Subject: [PATCH] Add episode queueing picker and configurable mpv video output Activating an episode now opens a scope picker (episode / season / whole show) and plays the chosen queue back-to-back in one mpv instance, with only the first item honoring its resume position. A new mpv `vo` driver setting (CLI flag, env var, config file, and settings TUI) lets users opt into terminal-based vos like sixel/kitty/tct, which auto-enable mpv's terminal input so playback keys still work. Co-Authored-By: Claude Opus 4.7 (1M context) --- config.toml.example | 7 + src/app.rs | 434 ++++++++++++++++++++++++++++++++++++++++---- src/config.rs | 12 ++ src/emby.rs | 27 +++ src/login.rs | 1 + src/main.rs | 8 +- src/player.rs | 85 +++++++-- src/settings.rs | 20 +- src/ui.rs | 139 ++++++++++++-- 9 files changed, 661 insertions(+), 72 deletions(-) diff --git a/config.toml.example b/config.toml.example index 207d79a..22ad488 100644 --- a/config.toml.example +++ b/config.toml.example @@ -6,3 +6,10 @@ password = "your-password-here" # codes in priority order; forwarded to mpv's `alang` / `slang`. # audio_lang = "jpn,eng" # subtitle_lang = "eng" + +# Optional mpv video output driver, forwarded as mpv's `vo` property. +# Common values: gpu (default), gpu-next, sixel, kitty, drm, tct, x11. +# Terminal-based vos (tct/sixel/kitty/caca) render inside the terminal; the +# client automatically enables mpv's terminal input for those so keys like +# q/space/arrow keys reach mpv. +# video_output = "sixel" diff --git a/src/app.rs b/src/app.rs index 33ccce0..4b159a0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::config::Config; use crate::emby::{Client, Item}; -use crate::player::Player; +use crate::player::{PlaybackOutcome, Player}; pub const RESUME_VIRTUAL_ID: &str = "__resume__"; pub const RESUME_TITLE: &str = "Continue Watching"; @@ -15,15 +15,55 @@ pub enum View { Items, } +/// One item in a playback queue. The first item in a queue honors +/// `start_ticks` (so resuming mid-episode works); subsequent items always +/// start from the beginning — see `App::play`. +#[derive(Debug, Clone)] +pub struct QueueItem { + pub id: String, + /// Display title (e.g. "Series — S01E02 — Episode Title"). + pub title: String, + /// Resume position from server-side UserData, in Emby ticks (100 ns units). + pub start_ticks: i64, +} + #[derive(Debug, Clone)] pub enum Action { - Play { - id: String, - /// Display title (e.g. "Series — S01E02 — Episode Title"). - title: String, - /// Resume position from server-side UserData, in Emby ticks (100 ns units). - start_ticks: i64, - }, + /// Play a queue of items in order. A single Movie or one-off click becomes + /// a one-element queue; selecting an Episode opens a picker that lets the + /// user choose between episode-only, rest-of-season, or rest-of-show. + Play { queue: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PickerScope { + Episode, + Season, + Show, +} + +/// Modal picker shown when the user activates an Episode: lets them choose how +/// much of the show to queue up. All three queues are precomputed so the user's +/// selection is instant. +#[derive(Debug, Clone)] +pub struct PlayPicker { + /// Header line for the popup — the clicked episode's display title. + pub title: String, + pub episode: Vec, + pub season: Vec, + pub show: Vec, + /// Currently highlighted scope. Defaults to Show. + pub cursor: PickerScope, +} + +impl PlayPicker { + pub fn take_queue(self, scope: PickerScope) -> Vec { + match scope { + PickerScope::Episode => self.episode, + PickerScope::Season => self.season, + PickerScope::Show => self.show, + } + } } pub struct App { @@ -48,6 +88,9 @@ pub struct App { /// Where to persist updated settings. `None` when no writable location was /// determined (rare; the settings screen will then refuse to save). pub config_path: Option, + /// Modal play-scope picker. `Some` means the popup is open and steals key + /// input from the main list. + pub picker: Option, } impl App { @@ -86,6 +129,7 @@ impl App { status, config, config_path, + picker: None, }) } @@ -183,18 +227,35 @@ impl App { .select(if self.items.is_empty() { None } else { Some(0) }); self.status = format!("{}: {} items", item.name, self.items.len()); Ok(None) + } else if item.item_type == "Episode" + && let Some(sid) = item.series_id.as_deref() + { + // Episodes open the play-scope picker so the user can + // queue this episode alone, the rest of the season, or + // the rest of the show. + let eps = self.client.series_episodes(sid).await?; + let start = eps.iter().position(|e| e.id == item.id).ok_or_else(|| { + anyhow::anyhow!( + "selected episode {} not present in series {sid}", + item.id + ) + })?; + let picker = build_picker(&eps, start, &item); + self.status = format!( + "Choose scope for {} ({} ep / {} season / {} show)", + picker.title, + picker.episode.len(), + picker.season.len(), + picker.show.len(), + ); + self.picker = Some(picker); + Ok(None) } else { - let start_ticks = item - .user_data - .as_ref() - .map(|ud| ud.position_ticks) - .unwrap_or(0); - let title = item.display_title(); - Ok(Some(Action::Play { - id: item.id, - title, - start_ticks, - })) + // Movies and anything else playable: no popup, just queue + // the single item. + let queue = vec![queue_item_from(&item)]; + self.status = format!("Playing {}", queue[0].title); + Ok(Some(Action::Play { queue })) } } } @@ -231,29 +292,113 @@ impl App { Ok(()) } + pub fn picker_move_up(&mut self) { + if let Some(p) = self.picker.as_mut() { + p.cursor = match p.cursor { + PickerScope::Episode => PickerScope::Episode, + PickerScope::Season => PickerScope::Episode, + PickerScope::Show => PickerScope::Season, + }; + } + } + + pub fn picker_move_down(&mut self) { + if let Some(p) = self.picker.as_mut() { + p.cursor = match p.cursor { + PickerScope::Episode => PickerScope::Season, + PickerScope::Season => PickerScope::Show, + PickerScope::Show => PickerScope::Show, + }; + } + } + + /// Confirm the picker selection. Returns the queue to play, or `None` if + /// the picked scope happens to be empty (defensive — shouldn't happen). + pub fn picker_confirm(&mut self) -> Option { + let picker = self.picker.take()?; + let scope = picker.cursor; + let queue = picker.take_queue(scope); + if queue.is_empty() { + return None; + } + self.status = match scope { + PickerScope::Episode => format!("Playing {}", queue[0].title), + PickerScope::Season => format!( + "Queued {} episodes (season) starting with {}", + queue.len(), + queue[0].title + ), + PickerScope::Show => format!( + "Queued {} episodes (show) starting with {}", + queue.len(), + queue[0].title + ), + }; + Some(Action::Play { queue }) + } + + pub fn picker_cancel(&mut self) { + self.picker = None; + self.status = "Cancelled".into(); + } + + pub fn pick_scope(&mut self, scope: PickerScope) -> Option { + if let Some(p) = self.picker.as_mut() { + p.cursor = scope; + } + self.picker_confirm() + } + pub fn play(&mut self, action: &Action) -> Result<()> { - match action { - Action::Play { - id, - title, - start_ticks, - } => { - let url = self.client.stream_url(id); - self.status = format!("Playing {title}..."); - let start_seconds = (*start_ticks as f64) / 10_000_000.0; - let reporter = self.client.reporter(id.clone()); - let mut player = Player::new( - self.config.audio_lang.as_deref(), - self.config.subtitle_lang.as_deref(), - )?; - let r = player.play_blocking(&url, title, start_seconds, Some(&reporter)); - self.status = match &r { - Ok(_) => format!("Finished: {title}"), - Err(e) => format!("Playback error: {e}"), - }; - r + let Action::Play { queue } = action; + if queue.is_empty() { + return Ok(()); + } + // One mpv instance for the whole queue: cheaper than re-initializing, + // and `force-window=yes` keeps the window between items. + let mut player = Player::new( + self.config.audio_lang.as_deref(), + self.config.subtitle_lang.as_deref(), + self.config.video_output.as_deref(), + )?; + let total = queue.len(); + let mut last_title = String::new(); + for (idx, qi) in queue.iter().enumerate() { + let url = self.client.stream_url(&qi.id); + // Only the first item resumes from its saved position; subsequent + // items always start at 0 — if the user queued forward through a + // season, they almost certainly want to watch the later episodes + // from the beginning regardless of any stale resume marker. + let start_seconds = if idx == 0 { + (qi.start_ticks as f64) / 10_000_000.0 + } else { + 0.0 + }; + self.status = format!("Playing {} ({}/{})", qi.title, idx + 1, total); + let reporter = self.client.reporter(qi.id.clone()); + last_title = qi.title.clone(); + match player.play_blocking(&url, &qi.title, start_seconds, Some(&reporter)) { + Ok(PlaybackOutcome::Finished) => continue, + Ok(PlaybackOutcome::Stopped) => { + self.status = if total > 1 { + format!("Stopped queue at {} ({}/{})", qi.title, idx + 1, total) + } else { + format!("Stopped: {}", qi.title) + }; + return Ok(()); + } + Err(e) => { + self.status = format!("Playback error on {}: {e}", qi.title); + return Err(e); + } } } + self.status = if total > 1 { + format!("Finished queue ({total} items, last: {last_title})") + } else { + format!("Finished: {last_title}") + }; + Ok(()) } pub fn breadcrumb_path(&self) -> String { @@ -278,6 +423,79 @@ fn restore_selection(saved: Option, len: usize) -> Option { Some(saved.unwrap_or(0).min(len - 1)) } +/// Build a playback queue from `items` starting at `start_idx`. +/// +/// Rules: +/// * If the starting item is an Episode, include it and walk forward through +/// subsequent playable Episodes that belong to the same series (matched by +/// `series_name`). Non-episodes / folders / different series break the run. +/// * Otherwise (Movie or anything else playable), the queue is just the +/// starting item. +/// +/// `start_idx` is assumed to be a valid playable (non-folder) index; callers +/// should have already filtered out folders. +fn build_queue(items: &[Item], start_idx: usize) -> Vec { + let Some(first) = items.get(start_idx) else { + return Vec::new(); + }; + let mut queue = vec![queue_item_from(first)]; + if first.item_type != "Episode" { + return queue; + } + let series = first.series_name.as_deref(); + for it in items.iter().skip(start_idx + 1) { + if it.is_folder || it.item_type != "Episode" { + break; + } + if it.series_name.as_deref() != series { + break; + } + queue.push(queue_item_from(it)); + } + queue +} + +/// Build the three queue choices for an Episode click. +/// `eps` is the full series episode list, `start_idx` is the clicked episode's +/// position in it, and `clicked` is the same item (we already have it on the +/// stack, no need to re-look it up). +fn build_picker(eps: &[Item], start_idx: usize, clicked: &Item) -> PlayPicker { + let episode = vec![queue_item_from(clicked)]; + let show = build_queue(eps, start_idx); + let target_season = clicked.parent_index_number; + let season: Vec = eps + .iter() + .skip(start_idx) + .take_while(|e| { + e.item_type == "Episode" + && !e.is_folder + && e.parent_index_number == target_season + && e.series_name.as_deref() == clicked.series_name.as_deref() + }) + .map(queue_item_from) + .collect(); + PlayPicker { + title: clicked.display_title(), + episode, + season, + show, + cursor: PickerScope::Show, + } +} + +fn queue_item_from(item: &Item) -> QueueItem { + let start_ticks = item + .user_data + .as_ref() + .map(|ud| ud.position_ticks) + .unwrap_or(0); + QueueItem { + id: item.id.clone(), + title: item.display_title(), + start_ticks, + } +} + pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec { let mut entries: Vec = Vec::with_capacity(libraries.len() + 1); if !resume.is_empty() { @@ -292,3 +510,141 @@ pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec Item { + Item { + id: id.into(), + name: format!("E{episode:02}"), + item_type: "Episode".into(), + series_name: Some(series.into()), + parent_index_number: Some(season), + index_number: Some(episode), + ..Item::default() + } + } + + fn movie(id: &str, name: &str) -> Item { + Item { + id: id.into(), + name: name.into(), + item_type: "Movie".into(), + ..Item::default() + } + } + + fn folder(id: &str, name: &str) -> Item { + Item { + id: id.into(), + name: name.into(), + item_type: "Season".into(), + is_folder: true, + ..Item::default() + } + } + + #[test] + fn queue_for_episode_includes_rest_of_same_series() { + let items = vec![ + ep("a", "Show", 1, 1), + ep("b", "Show", 1, 2), + ep("c", "Show", 1, 3), + ]; + let q = build_queue(&items, 1); + assert_eq!(q.len(), 2); + assert_eq!(q[0].id, "b"); + assert_eq!(q[1].id, "c"); + } + + #[test] + fn queue_spans_multiple_seasons_in_full_series_list() { + // Simulates /Shows/{id}/Episodes: a flat, ordered list across seasons. + let items = vec![ + ep("a", "Show", 1, 1), + ep("b", "Show", 1, 2), + ep("c", "Show", 2, 1), + ep("d", "Show", 2, 2), + ]; + let q = build_queue(&items, 1); + assert_eq!(q.len(), 3); + assert_eq!(q[0].id, "b"); + assert_eq!(q[1].id, "c"); + assert_eq!(q[2].id, "d"); + } + + #[test] + fn queue_breaks_on_different_series() { + let items = vec![ + ep("a", "Show", 1, 1), + ep("b", "Other", 1, 1), + ep("c", "Show", 1, 2), + ]; + let q = build_queue(&items, 0); + assert_eq!(q.len(), 1); + assert_eq!(q[0].id, "a"); + } + + #[test] + fn queue_breaks_on_folder_or_non_episode() { + let items = vec![ + ep("a", "Show", 1, 1), + folder("season2", "Season 2"), + ep("b", "Show", 2, 1), + ]; + let q = build_queue(&items, 0); + assert_eq!(q.len(), 1); + assert_eq!(q[0].id, "a"); + } + + #[test] + fn queue_for_movie_is_single_item() { + let items = vec![movie("m1", "Movie One"), movie("m2", "Movie Two")]; + let q = build_queue(&items, 0); + assert_eq!(q.len(), 1); + assert_eq!(q[0].id, "m1"); + } + + #[test] + fn queue_empty_for_out_of_bounds_index() { + let items: Vec = vec![]; + let q = build_queue(&items, 0); + assert!(q.is_empty()); + } + + #[test] + fn picker_splits_season_from_show() { + let eps = vec![ + ep("s1e1", "Show", 1, 1), + ep("s1e2", "Show", 1, 2), + ep("s1e3", "Show", 1, 3), + ep("s2e1", "Show", 2, 1), + ep("s2e2", "Show", 2, 2), + ]; + let clicked = eps[1].clone(); + let p = build_picker(&eps, 1, &clicked); + assert_eq!(p.episode.len(), 1); + assert_eq!(p.episode[0].id, "s1e2"); + // Season queue: s1e2, s1e3 — stops before s2e1. + assert_eq!(p.season.len(), 2); + assert_eq!(p.season[0].id, "s1e2"); + assert_eq!(p.season[1].id, "s1e3"); + // Show queue: s1e2, s1e3, s2e1, s2e2. + assert_eq!(p.show.len(), 4); + assert_eq!(p.show[0].id, "s1e2"); + assert_eq!(p.show[3].id, "s2e2"); + // Default cursor. + assert_eq!(p.cursor, PickerScope::Show); + } + + #[test] + fn picker_season_equals_show_when_only_one_season() { + let eps = vec![ep("a", "Show", 1, 1), ep("b", "Show", 1, 2)]; + let p = build_picker(&eps, 0, &eps[0]); + assert_eq!(p.season.len(), 2); + assert_eq!(p.show.len(), 2); + } +} diff --git a/src/config.rs b/src/config.rs index 79dd121..75c1e7c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,11 @@ pub struct CliArgs { #[arg(long, env = "EMBY_SUBTITLE_LANG")] pub subtitle_lang: Option, + /// mpv video output driver (e.g. `gpu`, `gpu-next`, `sixel`, `kitty`, + /// `drm`, `tct`). Forwarded to mpv's `vo`. Leave unset for mpv's default. + #[arg(long, env = "EMBY_VIDEO_OUTPUT")] + pub video_output: Option, + /// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml #[arg(short, long)] pub config: Option, @@ -44,6 +49,7 @@ pub struct FileConfig { pub password: Option, pub audio_lang: Option, pub subtitle_lang: Option, + pub video_output: Option, } #[derive(Debug, Clone)] @@ -53,6 +59,9 @@ pub struct Config { pub password: String, pub audio_lang: Option, pub subtitle_lang: Option, + /// mpv `vo` driver, e.g. `gpu`, `sixel`, `kitty`, `drm`, `tct`. `None` + /// means we don't touch the property and mpv picks its default. + pub video_output: Option, } pub fn load(args: &CliArgs) -> Result<(Option, Option)> { @@ -63,6 +72,7 @@ pub fn load(args: &CliArgs) -> Result<(Option, Option)> { let password = args.password.clone().or(file_cfg.password); let audio_lang = args.audio_lang.clone().or(file_cfg.audio_lang); let subtitle_lang = args.subtitle_lang.clone().or(file_cfg.subtitle_lang); + let video_output = args.video_output.clone().or(file_cfg.video_output); let cfg = match (server, user, password) { (Some(server), Some(user), Some(password)) => Some(Config { @@ -71,6 +81,7 @@ pub fn load(args: &CliArgs) -> Result<(Option, Option)> { password, audio_lang, subtitle_lang, + video_output, }), _ => None, }; @@ -124,6 +135,7 @@ pub fn save(path: &Path, cfg: &Config) -> Result<()> { password: Some(cfg.password.clone()), audio_lang: cfg.audio_lang.clone(), subtitle_lang: cfg.subtitle_lang.clone(), + video_output: cfg.video_output.clone(), }; let text = toml::to_string_pretty(&file_cfg).context("serializing config")?; std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; diff --git a/src/emby.rs b/src/emby.rs index d724549..3c406c9 100644 --- a/src/emby.rs +++ b/src/emby.rs @@ -59,6 +59,10 @@ pub struct Item { pub runtime_ticks: Option, #[serde(rename = "SeriesName", default)] pub series_name: Option, + /// Set for Episode items. Lets us fetch the full series episode list so a + /// queue started mid-show can continue past the current season. + #[serde(rename = "SeriesId", default)] + pub series_id: Option, #[serde(rename = "IndexNumber", default)] pub index_number: Option, #[serde(rename = "ParentIndexNumber", default)] @@ -223,6 +227,29 @@ impl Client { Ok(resp.items) } + /// All episodes for a series, in playback order (season, then episode). + /// Used to build a multi-season playback queue starting from any episode. + pub async fn series_episodes(&self, series_id: &str) -> Result> { + let url = format!("{}/Shows/{}/Episodes", self.server, series_id); + let resp: ItemsResponse = self + .http + .get(&url) + .headers(self.headers()) + .query(&[ + ("UserId", self.user_id.as_str()), + ( + "Fields", + "ProductionYear,RunTimeTicks,SeriesName,UserData", + ), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(resp.items) + } + pub fn stream_url(&self, item_id: &str) -> String { format!( "{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}", diff --git a/src/login.rs b/src/login.rs index 1b7c81d..e3627bf 100644 --- a/src/login.rs +++ b/src/login.rs @@ -139,6 +139,7 @@ fn run( password: form.password.clone(), audio_lang: None, subtitle_lang: None, + video_output: None, }); } KeyCode::Char(' ') if form.focus == Field::SaveToggle => { diff --git a/src/main.rs b/src/main.rs index fe9bb9b..45dd12f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,14 +30,18 @@ async fn main() -> Result<()> { } }; - // CLI/env language flags still apply even when credentials came from the - // interactive prompt (which doesn't ask for languages). + // CLI/env language and video-output flags still apply even when + // credentials came from the interactive prompt (which doesn't ask for + // these). if cfg.audio_lang.is_none() { cfg.audio_lang = args.audio_lang.clone(); } if cfg.subtitle_lang.is_none() { cfg.subtitle_lang = args.subtitle_lang.clone(); } + if cfg.video_output.is_none() { + cfg.video_output = args.video_output.clone(); + } let client = emby::Client::new(&cfg).await?; let mut app = app::App::new(client, cfg, save_target).await?; diff --git a/src/player.rs b/src/player.rs index 1b280ce..60b80f8 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,5 +1,5 @@ use anyhow::{Result, anyhow}; -use libmpv2::{Mpv, events::Event}; +use libmpv2::{Mpv, events::Event, mpv_end_file_reason}; use std::time::{Duration, Instant}; use crate::emby::Reporter; @@ -8,19 +8,41 @@ pub struct Player { mpv: Mpv, } +/// Why a single `play_blocking` call returned. Used by the queue loop to +/// decide whether to advance to the next item or stop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlaybackOutcome { + /// File ended naturally (EOF). Advance to the next queue item. + Finished, + /// User quit mpv or mpv shut down. Stop the queue. + Stopped, +} + impl Player { - pub fn new(audio_lang: Option<&str>, subtitle_lang: Option<&str>) -> Result { + pub fn new( + audio_lang: Option<&str>, + subtitle_lang: Option<&str>, + video_output: Option<&str>, + ) -> Result { let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?; + // Terminal-based vos render to stdout and need the controlling tty for + // both their output and keyboard input. For everything else we keep + // mpv off the terminal so our TUI restore logic stays clean. + let terminal_vo = matches!(video_output, Some("tct" | "sixel" | "kitty" | "caca")); for (k, v) in [ ("force-window", "yes"), ("input-default-bindings", "yes"), ("input-vo-keyboard", "yes"), ("osc", "yes"), ("keep-open", "no"), - ("terminal", "no"), + ("terminal", if terminal_vo { "yes" } else { "no" }), ] { let _ = mpv.set_property(k, v); } + if let Some(vo) = video_output { + mpv.set_property("vo", vo) + .map_err(|e| anyhow!("mpv rejected video output `{vo}`: {e}"))?; + } // Default audio/subtitle track preference. mpv parses these as a // comma-separated priority list of ISO 639 language codes. if let Some(alang) = audio_lang { @@ -36,19 +58,26 @@ impl Player { /// `title` is displayed in mpv's OSC/window title (instead of the raw URL). /// `start_seconds` resumes from a saved position when > 0. /// `reporter`, if present, posts Start/Progress/Stopped events to Emby. + /// + /// The returned `PlaybackOutcome` lets the caller distinguish natural end + /// (advance the queue) from user quit (stop the queue). pub fn play_blocking( &mut self, url: &str, title: &str, start_seconds: f64, reporter: Option<&Reporter>, - ) -> Result<()> { + ) -> Result { let seek_target = format!("{:.3}", start_seconds.max(0.0)); // mpv's `start` option is consumed by the next loadfile call. It's a time // spec parsed as a string — passing an f64 fails with PROPERTY_FORMAT and - // is silently dropped, so always go through the string path. + // is silently dropped, so always go through the string path. Explicitly + // clear `start` for items without resume position so a stale value from a + // previous queue item doesn't leak into this one. if start_seconds > 0.5 { let _ = self.mpv.set_property("start", seek_target.as_str()); + } else { + let _ = self.mpv.set_property("start", "none"); } // Overrides the title mpv would otherwise compute from the URL. let _ = self.mpv.set_property("force-media-title", title); @@ -71,19 +100,45 @@ impl Player { let mut last_report = Instant::now(); let report_every = Duration::from_secs(10); - loop { + let outcome = loop { // Borrow event_context only for the wait, so we can re-borrow `self.mpv` // for property reads / commands after each tick. let mut do_seek = false; - let break_now; + let break_with: Option; { let ctx = self.mpv.event_context_mut(); match ctx.wait_event(1.0) { - Some(Ok(Event::EndFile(_))) | Some(Ok(Event::Shutdown)) => { - break_now = true; + Some(Ok(Event::EndFile(reason))) => { + // EOF = natural end, advance the queue. Quit/Stop = user + // asked us to stop. Redirect = mpv is loading a different + // URL automatically; keep waiting for the real end. Error + // = surface as a hard error so the queue aborts. + match reason { + mpv_end_file_reason::Eof => { + break_with = Some(PlaybackOutcome::Finished); + } + mpv_end_file_reason::Quit | mpv_end_file_reason::Stop => { + break_with = Some(PlaybackOutcome::Stopped); + } + mpv_end_file_reason::Redirect => { + break_with = None; + } + mpv_end_file_reason::Error => { + if let Some(r) = reporter { + let _ = r.stopped(last_pos_ticks); + } + return Err(anyhow!("mpv reported playback error")); + } + _ => { + break_with = Some(PlaybackOutcome::Stopped); + } + } + } + Some(Ok(Event::Shutdown)) => { + break_with = Some(PlaybackOutcome::Stopped); } Some(Ok(Event::FileLoaded)) => { - break_now = false; + break_with = None; if needs_seek { do_seek = true; needs_seek = false; @@ -96,12 +151,12 @@ impl Player { return Err(anyhow!("mpv event error: {e}")); } _ => { - break_now = false; + break_with = None; } } } - if break_now { - break; + if let Some(outcome) = break_with { + break outcome; } if do_seek { let _ = self @@ -121,12 +176,12 @@ impl Player { let _ = r.progress(last_pos_ticks, paused); last_report = Instant::now(); } - } + }; if let Some(r) = reporter { let _ = r.stopped(last_pos_ticks); } - Ok(()) + Ok(outcome) } } diff --git a/src/settings.rs b/src/settings.rs index eb7225e..d556139 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -20,14 +20,16 @@ enum Field { Password, AudioLang, SubtitleLang, + VideoOutput, } -const FIELDS: [Field; 5] = [ +const FIELDS: [Field; 6] = [ Field::Server, Field::User, Field::Password, Field::AudioLang, Field::SubtitleLang, + Field::VideoOutput, ]; impl Field { @@ -47,6 +49,7 @@ struct Form { password: String, audio_lang: String, subtitle_lang: String, + video_output: String, focus: Field, error: Option, } @@ -59,6 +62,7 @@ impl Form { password: cfg.password.clone(), audio_lang: cfg.audio_lang.clone().unwrap_or_default(), subtitle_lang: cfg.subtitle_lang.clone().unwrap_or_default(), + video_output: cfg.video_output.clone().unwrap_or_default(), focus: Field::Server, error: None, } @@ -71,6 +75,7 @@ impl Form { Field::Password => &mut self.password, Field::AudioLang => &mut self.audio_lang, Field::SubtitleLang => &mut self.subtitle_lang, + Field::VideoOutput => &mut self.video_output, } } } @@ -139,6 +144,7 @@ async fn apply(form: &mut Form, app: &mut App) -> Result<()> { password: form.password.clone(), audio_lang: trim_to_opt(&form.audio_lang), subtitle_lang: trim_to_opt(&form.subtitle_lang), + video_output: trim_to_opt(&form.video_output), }; let creds_changed = new_cfg.server != app.config.server @@ -208,6 +214,7 @@ fn draw(f: &mut ratatui::Frame, form: &Form) { Constraint::Length(3), // password Constraint::Length(3), // audio lang Constraint::Length(3), // subtitle lang + Constraint::Length(3), // video output Constraint::Min(1), // error / help ]) .split(inner); @@ -253,6 +260,15 @@ fn draw(f: &mut ratatui::Frame, form: &Form) { ), rows[5], ); + f.render_widget( + field_widget( + "Video output (gpu/sixel/kitty/drm/tct, blank=mpv default)", + &form.video_output, + form.focus == Field::VideoOutput, + false, + ), + rows[6], + ); let footer = if let Some(err) = &form.error { Line::from(Span::styled( @@ -265,5 +281,5 @@ fn draw(f: &mut ratatui::Frame, form: &Form) { Style::default().fg(Color::DarkGray), )) }; - f.render_widget(Paragraph::new(footer), rows[6]); + f.render_widget(Paragraph::new(footer), rows[7]); } diff --git a/src/ui.rs b/src/ui.rs index da7fe03..cc71d3a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,14 +6,14 @@ use crossterm::terminal::{ }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use std::io::{Stdout, stdout}; use std::time::Duration; -use crate::app::{App, RESUME_VIRTUAL_ID, View}; +use crate::app::{App, Action, PickerScope, PlayPicker, RESUME_VIRTUAL_ID, View}; use crate::emby::Item; use crate::settings; @@ -52,6 +52,21 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { if key.kind != KeyEventKind::Press { continue; } + // Force-quit wins even over the picker so a stuck popup can't trap the + // user. + if let KeyCode::Char('Q') = key.code { + break; + } + // Picker steals input when open: navigation and shortcut keys map to + // the popup, not the main list. + if app.picker.is_some() { + let picker_action = handle_picker_key(app, key.code); + if let Some(action) = picker_action { + play_with_teardown(term, app, &action)?; + app.refresh_resume().await; + } + continue; + } match key.code { KeyCode::Char('q') => match app.view { View::Items => app.back().await?, @@ -76,16 +91,7 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { let action = app.activate().await?; if let Some(action) = action { - // Tear down TUI before mpv takes over (it spawns its own window - // but we still want a clean terminal in case of -vo tct etc.). - restore_terminal(term)?; - let r = app.play(&action); - *term = setup_terminal()?; - term.clear()?; - if let Err(e) = r { - app.status = format!("playback error: {e}"); - } - // Resume positions likely changed; refresh in the background view. + play_with_teardown(term, app, &action)?; app.refresh_resume().await; } } @@ -97,6 +103,44 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> { Ok(()) } +/// Maps a key press to a picker action. Returns `Some(Action)` if the user +/// confirmed a queue choice (caller should start playback); returns `None` if +/// the key was handled internally (navigation, cancel) or ignored. +fn handle_picker_key(app: &mut App, code: KeyCode) -> Option { + match code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => { + app.picker_cancel(); + None + } + KeyCode::Up | KeyCode::Char('k') => { + app.picker_move_up(); + None + } + KeyCode::Down | KeyCode::Char('j') => { + app.picker_move_down(); + None + } + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => app.picker_confirm(), + KeyCode::Char('e') | KeyCode::Char('E') => app.pick_scope(PickerScope::Episode), + KeyCode::Char('s') | KeyCode::Char('S') => app.pick_scope(PickerScope::Season), + KeyCode::Char('w') | KeyCode::Char('W') => app.pick_scope(PickerScope::Show), + _ => None, + } +} + +/// Tear down the TUI, run mpv (blocking), then restore the TUI. Used by both +/// the main list activation path and the picker confirm path. +fn play_with_teardown(term: &mut Term, app: &mut App, action: &Action) -> Result<()> { + restore_terminal(term)?; + let r = app.play(action); + *term = setup_terminal()?; + term.clear()?; + if let Err(e) = r { + app.status = format!("playback error: {e}"); + } + Ok(()) +} + fn draw(f: &mut ratatui::Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -149,7 +193,11 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) { f.render_stateful_widget(list, chunks[1], &mut app.list_state); // Status bar - let help = "↑/↓ move · Enter open/play · Backspace back · s settings · q quit (Q force)"; + let help = if app.picker.is_some() { + "↑/↓ select · Enter confirm · [E]pisode [S]eason [W]hole show · Esc cancel" + } else { + "↑/↓ move · Enter open/play · Backspace back · s settings · q quit (Q force)" + }; let status = Paragraph::new(vec![ Line::from(Span::styled( app.status.clone(), @@ -159,6 +207,69 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) { ]) .block(Block::default().borders(Borders::ALL)); f.render_widget(status, chunks[2]); + + if let Some(picker) = app.picker.as_ref() { + draw_picker(f, picker); + } +} + +/// Render the play-scope popup centered over the main area. +fn draw_picker(f: &mut ratatui::Frame, picker: &PlayPicker) { + let area = centered_rect(60, 9, f.area()); + f.render_widget(Clear, area); + let rows = [ + (PickerScope::Episode, 'E', "this episode only".to_string(), 1usize), + ( + PickerScope::Season, + 'S', + "season".to_string(), + picker.season.len(), + ), + ( + PickerScope::Show, + 'W', + "whole show".to_string(), + picker.show.len(), + ), + ]; + let items: Vec = rows + .iter() + .map(|(scope, key, label, count)| { + let selected = *scope == picker.cursor; + let prefix = if selected { ">> " } else { " " }; + let style = if selected { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let count_suffix = if *count == 1 { + " (1 ep)".to_string() + } else { + format!(" ({count} eps)") + }; + ListItem::new(Line::from(vec![Span::styled( + format!("{prefix}[{key}] {label}{count_suffix}"), + style, + )])) + }) + .collect(); + let title = format!(" Play '{}' ", picker.title); + let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); + f.render_widget(list, area); +} + +/// Centered rect of given absolute width (capped at 80% of parent) and height. +fn centered_rect(width: u16, height: u16, parent: Rect) -> Rect { + let max_w = (parent.width * 8 / 10).max(20); + let max_h = (parent.height * 8 / 10).max(5); + let w = width.min(max_w); + let h = height.min(max_h); + let x = parent.x + parent.width.saturating_sub(w) / 2; + let y = parent.y + parent.height.saturating_sub(h) / 2; + Rect::new(x, y, w, h) } fn format_item(it: &Item) -> ListItem<'static> {