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) <[email protected]>
This commit is contained in:
janisslsmandClaude Opus 4.7 committed 2026-05-22 15:46:17 +03:00
1 parent f0ebb4b9a4
commit 093b13f03e
9 files changed
+656 -67

No files matched your search

+7
View File
@@ -6,3 +6,10 @@ password = "your-password-here"
# codes in priority order; forwarded to mpv's `alang` / `slang`. # codes in priority order; forwarded to mpv's `alang` / `slang`.
# audio_lang = "jpn,eng" # audio_lang = "jpn,eng"
# subtitle_lang = "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"
+390 -34
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use crate::config::Config; use crate::config::Config;
use crate::emby::{Client, Item}; use crate::emby::{Client, Item};
use crate::player::Player; use crate::player::{PlaybackOutcome, Player};
pub const RESUME_VIRTUAL_ID: &str = "__resume__"; pub const RESUME_VIRTUAL_ID: &str = "__resume__";
pub const RESUME_TITLE: &str = "Continue Watching"; pub const RESUME_TITLE: &str = "Continue Watching";
@@ -15,15 +15,55 @@ pub enum View {
Items, 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)] #[derive(Debug, Clone)]
pub enum Action { pub enum Action {
Play { /// Play a queue of items in order. A single Movie or one-off click becomes
id: String, /// a one-element queue; selecting an Episode opens a picker that lets the
/// Display title (e.g. "Series — S01E02 — Episode Title"). /// user choose between episode-only, rest-of-season, or rest-of-show.
title: String, Play { queue: Vec<QueueItem> },
/// Resume position from server-side UserData, in Emby ticks (100 ns units). }
start_ticks: i64,
}, #[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<QueueItem>,
pub season: Vec<QueueItem>,
pub show: Vec<QueueItem>,
/// Currently highlighted scope. Defaults to Show.
pub cursor: PickerScope,
}
impl PlayPicker {
pub fn take_queue(self, scope: PickerScope) -> Vec<QueueItem> {
match scope {
PickerScope::Episode => self.episode,
PickerScope::Season => self.season,
PickerScope::Show => self.show,
}
}
} }
pub struct App { pub struct App {
@@ -48,6 +88,9 @@ pub struct App {
/// Where to persist updated settings. `None` when no writable location was /// Where to persist updated settings. `None` when no writable location was
/// determined (rare; the settings screen will then refuse to save). /// determined (rare; the settings screen will then refuse to save).
pub config_path: Option<PathBuf>, pub config_path: Option<PathBuf>,
/// Modal play-scope picker. `Some` means the popup is open and steals key
/// input from the main list.
pub picker: Option<PlayPicker>,
} }
impl App { impl App {
@@ -86,6 +129,7 @@ impl App {
status, status,
config, config,
config_path, config_path,
picker: None,
}) })
} }
@@ -183,18 +227,35 @@ impl App {
.select(if self.items.is_empty() { None } else { Some(0) }); .select(if self.items.is_empty() { None } else { Some(0) });
self.status = format!("{}: {} items", item.name, self.items.len()); self.status = format!("{}: {} items", item.name, self.items.len());
Ok(None) 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 { } else {
let start_ticks = item // Movies and anything else playable: no popup, just queue
.user_data // the single item.
.as_ref() let queue = vec![queue_item_from(&item)];
.map(|ud| ud.position_ticks) self.status = format!("Playing {}", queue[0].title);
.unwrap_or(0); Ok(Some(Action::Play { queue }))
let title = item.display_title();
Ok(Some(Action::Play {
id: item.id,
title,
start_ticks,
}))
} }
} }
} }
@@ -231,30 +292,114 @@ impl App {
Ok(()) 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<Action> {
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<Action> {
if let Some(p) = self.picker.as_mut() {
p.cursor = scope;
}
self.picker_confirm()
}
pub fn play(&mut self, action: &Action) -> Result<()> { pub fn play(&mut self, action: &Action) -> Result<()> {
match action { let Action::Play { queue } = action;
Action::Play { if queue.is_empty() {
id, return Ok(());
title, }
start_ticks, // One mpv instance for the whole queue: cheaper than re-initializing,
} => { // and `force-window=yes` keeps the window between items.
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( let mut player = Player::new(
self.config.audio_lang.as_deref(), self.config.audio_lang.as_deref(),
self.config.subtitle_lang.as_deref(), self.config.subtitle_lang.as_deref(),
self.config.video_output.as_deref(),
)?; )?;
let r = player.play_blocking(&url, title, start_seconds, Some(&reporter)); let total = queue.len();
self.status = match &r { let mut last_title = String::new();
Ok(_) => format!("Finished: {title}"), for (idx, qi) in queue.iter().enumerate() {
Err(e) => format!("Playback error: {e}"), 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
}; };
r 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 { pub fn breadcrumb_path(&self) -> String {
if self.breadcrumbs.is_empty() { if self.breadcrumbs.is_empty() {
@@ -278,6 +423,79 @@ fn restore_selection(saved: Option<usize>, len: usize) -> Option<usize> {
Some(saved.unwrap_or(0).min(len - 1)) 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<QueueItem> {
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<QueueItem> = 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<Item> { pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Item> {
let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1); let mut entries: Vec<Item> = Vec::with_capacity(libraries.len() + 1);
if !resume.is_empty() { if !resume.is_empty() {
@@ -292,3 +510,141 @@ pub(crate) fn build_home_entries(libraries: &[Item], resume: &[Item]) -> Vec<Ite
entries.extend(libraries.iter().cloned()); entries.extend(libraries.iter().cloned());
entries entries
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::emby::Item;
fn ep(id: &str, series: &str, season: i32, episode: i32) -> 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<Item> = 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);
}
}
+12
View File
@@ -28,6 +28,11 @@ pub struct CliArgs {
#[arg(long, env = "EMBY_SUBTITLE_LANG")] #[arg(long, env = "EMBY_SUBTITLE_LANG")]
pub subtitle_lang: Option<String>, pub subtitle_lang: Option<String>,
/// 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<String>,
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml /// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
#[arg(short, long)] #[arg(short, long)]
pub config: Option<PathBuf>, pub config: Option<PathBuf>,
@@ -44,6 +49,7 @@ pub struct FileConfig {
pub password: Option<String>, pub password: Option<String>,
pub audio_lang: Option<String>, pub audio_lang: Option<String>,
pub subtitle_lang: Option<String>, pub subtitle_lang: Option<String>,
pub video_output: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -53,6 +59,9 @@ pub struct Config {
pub password: String, pub password: String,
pub audio_lang: Option<String>, pub audio_lang: Option<String>,
pub subtitle_lang: Option<String>, pub subtitle_lang: Option<String>,
/// 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<String>,
} }
pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> { pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
@@ -63,6 +72,7 @@ pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
let password = args.password.clone().or(file_cfg.password); let password = args.password.clone().or(file_cfg.password);
let audio_lang = args.audio_lang.clone().or(file_cfg.audio_lang); 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 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) { let cfg = match (server, user, password) {
(Some(server), Some(user), Some(password)) => Some(Config { (Some(server), Some(user), Some(password)) => Some(Config {
@@ -71,6 +81,7 @@ pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
password, password,
audio_lang, audio_lang,
subtitle_lang, subtitle_lang,
video_output,
}), }),
_ => None, _ => None,
}; };
@@ -124,6 +135,7 @@ pub fn save(path: &Path, cfg: &Config) -> Result<()> {
password: Some(cfg.password.clone()), password: Some(cfg.password.clone()),
audio_lang: cfg.audio_lang.clone(), audio_lang: cfg.audio_lang.clone(),
subtitle_lang: cfg.subtitle_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")?; let text = toml::to_string_pretty(&file_cfg).context("serializing config")?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?; std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
+27
View File
@@ -59,6 +59,10 @@ pub struct Item {
pub runtime_ticks: Option<i64>, pub runtime_ticks: Option<i64>,
#[serde(rename = "SeriesName", default)] #[serde(rename = "SeriesName", default)]
pub series_name: Option<String>, pub series_name: Option<String>,
/// 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<String>,
#[serde(rename = "IndexNumber", default)] #[serde(rename = "IndexNumber", default)]
pub index_number: Option<i32>, pub index_number: Option<i32>,
#[serde(rename = "ParentIndexNumber", default)] #[serde(rename = "ParentIndexNumber", default)]
@@ -223,6 +227,29 @@ impl Client {
Ok(resp.items) 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<Vec<Item>> {
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 { pub fn stream_url(&self, item_id: &str) -> String {
format!( format!(
"{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}", "{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}",
+1
View File
@@ -139,6 +139,7 @@ fn run<B: ratatui::backend::Backend>(
password: form.password.clone(), password: form.password.clone(),
audio_lang: None, audio_lang: None,
subtitle_lang: None, subtitle_lang: None,
video_output: None,
}); });
} }
KeyCode::Char(' ') if form.focus == Field::SaveToggle => { KeyCode::Char(' ') if form.focus == Field::SaveToggle => {
+6 -2
View File
@@ -30,14 +30,18 @@ async fn main() -> Result<()> {
} }
}; };
// CLI/env language flags still apply even when credentials came from the // CLI/env language and video-output flags still apply even when
// interactive prompt (which doesn't ask for languages). // credentials came from the interactive prompt (which doesn't ask for
// these).
if cfg.audio_lang.is_none() { if cfg.audio_lang.is_none() {
cfg.audio_lang = args.audio_lang.clone(); cfg.audio_lang = args.audio_lang.clone();
} }
if cfg.subtitle_lang.is_none() { if cfg.subtitle_lang.is_none() {
cfg.subtitle_lang = args.subtitle_lang.clone(); 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 client = emby::Client::new(&cfg).await?;
let mut app = app::App::new(client, cfg, save_target).await?; let mut app = app::App::new(client, cfg, save_target).await?;
+70 -15
View File
@@ -1,5 +1,5 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use libmpv2::{Mpv, events::Event}; use libmpv2::{Mpv, events::Event, mpv_end_file_reason};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::emby::Reporter; use crate::emby::Reporter;
@@ -8,19 +8,41 @@ pub struct Player {
mpv: Mpv, 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 { impl Player {
pub fn new(audio_lang: Option<&str>, subtitle_lang: Option<&str>) -> Result<Self> { pub fn new(
audio_lang: Option<&str>,
subtitle_lang: Option<&str>,
video_output: Option<&str>,
) -> Result<Self> {
let mpv = Mpv::new().map_err(|e| anyhow!("mpv init failed: {e}"))?; 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 [ for (k, v) in [
("force-window", "yes"), ("force-window", "yes"),
("input-default-bindings", "yes"), ("input-default-bindings", "yes"),
("input-vo-keyboard", "yes"), ("input-vo-keyboard", "yes"),
("osc", "yes"), ("osc", "yes"),
("keep-open", "no"), ("keep-open", "no"),
("terminal", "no"), ("terminal", if terminal_vo { "yes" } else { "no" }),
] { ] {
let _ = mpv.set_property(k, v); 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 // Default audio/subtitle track preference. mpv parses these as a
// comma-separated priority list of ISO 639 language codes. // comma-separated priority list of ISO 639 language codes.
if let Some(alang) = audio_lang { 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). /// `title` is displayed in mpv's OSC/window title (instead of the raw URL).
/// `start_seconds` resumes from a saved position when > 0. /// `start_seconds` resumes from a saved position when > 0.
/// `reporter`, if present, posts Start/Progress/Stopped events to Emby. /// `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( pub fn play_blocking(
&mut self, &mut self,
url: &str, url: &str,
title: &str, title: &str,
start_seconds: f64, start_seconds: f64,
reporter: Option<&Reporter>, reporter: Option<&Reporter>,
) -> Result<()> { ) -> Result<PlaybackOutcome> {
let seek_target = format!("{:.3}", start_seconds.max(0.0)); 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 // 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 // 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 { if start_seconds > 0.5 {
let _ = self.mpv.set_property("start", seek_target.as_str()); 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. // Overrides the title mpv would otherwise compute from the URL.
let _ = self.mpv.set_property("force-media-title", title); let _ = self.mpv.set_property("force-media-title", title);
@@ -71,19 +100,45 @@ impl Player {
let mut last_report = Instant::now(); let mut last_report = Instant::now();
let report_every = Duration::from_secs(10); 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` // Borrow event_context only for the wait, so we can re-borrow `self.mpv`
// for property reads / commands after each tick. // for property reads / commands after each tick.
let mut do_seek = false; let mut do_seek = false;
let break_now; let break_with: Option<PlaybackOutcome>;
{ {
let ctx = self.mpv.event_context_mut(); let ctx = self.mpv.event_context_mut();
match ctx.wait_event(1.0) { match ctx.wait_event(1.0) {
Some(Ok(Event::EndFile(_))) | Some(Ok(Event::Shutdown)) => { Some(Ok(Event::EndFile(reason))) => {
break_now = true; // 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)) => { Some(Ok(Event::FileLoaded)) => {
break_now = false; break_with = None;
if needs_seek { if needs_seek {
do_seek = true; do_seek = true;
needs_seek = false; needs_seek = false;
@@ -96,12 +151,12 @@ impl Player {
return Err(anyhow!("mpv event error: {e}")); return Err(anyhow!("mpv event error: {e}"));
} }
_ => { _ => {
break_now = false; break_with = None;
} }
} }
} }
if break_now { if let Some(outcome) = break_with {
break; break outcome;
} }
if do_seek { if do_seek {
let _ = self let _ = self
@@ -121,12 +176,12 @@ impl Player {
let _ = r.progress(last_pos_ticks, paused); let _ = r.progress(last_pos_ticks, paused);
last_report = Instant::now(); last_report = Instant::now();
} }
} };
if let Some(r) = reporter { if let Some(r) = reporter {
let _ = r.stopped(last_pos_ticks); let _ = r.stopped(last_pos_ticks);
} }
Ok(()) Ok(outcome)
} }
} }
+18 -2
View File
@@ -20,14 +20,16 @@ enum Field {
Password, Password,
AudioLang, AudioLang,
SubtitleLang, SubtitleLang,
VideoOutput,
} }
const FIELDS: [Field; 5] = [ const FIELDS: [Field; 6] = [
Field::Server, Field::Server,
Field::User, Field::User,
Field::Password, Field::Password,
Field::AudioLang, Field::AudioLang,
Field::SubtitleLang, Field::SubtitleLang,
Field::VideoOutput,
]; ];
impl Field { impl Field {
@@ -47,6 +49,7 @@ struct Form {
password: String, password: String,
audio_lang: String, audio_lang: String,
subtitle_lang: String, subtitle_lang: String,
video_output: String,
focus: Field, focus: Field,
error: Option<String>, error: Option<String>,
} }
@@ -59,6 +62,7 @@ impl Form {
password: cfg.password.clone(), password: cfg.password.clone(),
audio_lang: cfg.audio_lang.clone().unwrap_or_default(), audio_lang: cfg.audio_lang.clone().unwrap_or_default(),
subtitle_lang: cfg.subtitle_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, focus: Field::Server,
error: None, error: None,
} }
@@ -71,6 +75,7 @@ impl Form {
Field::Password => &mut self.password, Field::Password => &mut self.password,
Field::AudioLang => &mut self.audio_lang, Field::AudioLang => &mut self.audio_lang,
Field::SubtitleLang => &mut self.subtitle_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(), password: form.password.clone(),
audio_lang: trim_to_opt(&form.audio_lang), audio_lang: trim_to_opt(&form.audio_lang),
subtitle_lang: trim_to_opt(&form.subtitle_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 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), // password
Constraint::Length(3), // audio lang Constraint::Length(3), // audio lang
Constraint::Length(3), // subtitle lang Constraint::Length(3), // subtitle lang
Constraint::Length(3), // video output
Constraint::Min(1), // error / help Constraint::Min(1), // error / help
]) ])
.split(inner); .split(inner);
@@ -253,6 +260,15 @@ fn draw(f: &mut ratatui::Frame, form: &Form) {
), ),
rows[5], 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 { let footer = if let Some(err) = &form.error {
Line::from(Span::styled( Line::from(Span::styled(
@@ -265,5 +281,5 @@ fn draw(f: &mut ratatui::Frame, form: &Form) {
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)) ))
}; };
f.render_widget(Paragraph::new(footer), rows[6]); f.render_widget(Paragraph::new(footer), rows[7]);
} }
+125 -14
View File
@@ -6,14 +6,14 @@ use crossterm::terminal::{
}; };
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; 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::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; 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::io::{Stdout, stdout};
use std::time::Duration; 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::emby::Item;
use crate::settings; use crate::settings;
@@ -52,6 +52,21 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> {
if key.kind != KeyEventKind::Press { if key.kind != KeyEventKind::Press {
continue; 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 { match key.code {
KeyCode::Char('q') => match app.view { KeyCode::Char('q') => match app.view {
View::Items => app.back().await?, 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 => { KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
let action = app.activate().await?; let action = app.activate().await?;
if let Some(action) = action { if let Some(action) = action {
// Tear down TUI before mpv takes over (it spawns its own window play_with_teardown(term, app, &action)?;
// 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.
app.refresh_resume().await; app.refresh_resume().await;
} }
} }
@@ -97,6 +103,44 @@ async fn run_loop(term: &mut Term, app: &mut App) -> Result<()> {
Ok(()) 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<Action> {
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) { fn draw(f: &mut ratatui::Frame, app: &mut App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .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); f.render_stateful_widget(list, chunks[1], &mut app.list_state);
// Status bar // 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![ let status = Paragraph::new(vec![
Line::from(Span::styled( Line::from(Span::styled(
app.status.clone(), app.status.clone(),
@@ -159,6 +207,69 @@ fn draw(f: &mut ratatui::Frame, app: &mut App) {
]) ])
.block(Block::default().borders(Borders::ALL)); .block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[2]); 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<ListItem> = 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> { fn format_item(it: &Item) -> ListItem<'static> {