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:
1 parent
f0ebb4b9a4
commit
093b13f03e
9 files changed
+656
-67
No files matched your search
@@ -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"
|
||||
+390
-34
@@ -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<QueueItem> },
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -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<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 {
|
||||
@@ -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,30 +292,114 @@ 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<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<()> {
|
||||
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 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 r = player.play_blocking(&url, title, start_seconds, Some(&reporter));
|
||||
self.status = match &r {
|
||||
Ok(_) => format!("Finished: {title}"),
|
||||
Err(e) => format!("Playback error: {e}"),
|
||||
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
|
||||
};
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut entries: Vec<Item> = 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<Ite
|
||||
entries.extend(libraries.iter().cloned());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,11 @@ pub struct CliArgs {
|
||||
#[arg(long, env = "EMBY_SUBTITLE_LANG")]
|
||||
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
|
||||
#[arg(short, long)]
|
||||
pub config: Option<PathBuf>,
|
||||
@@ -44,6 +49,7 @@ pub struct FileConfig {
|
||||
pub password: Option<String>,
|
||||
pub audio_lang: Option<String>,
|
||||
pub subtitle_lang: Option<String>,
|
||||
pub video_output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -53,6 +59,9 @@ pub struct Config {
|
||||
pub password: String,
|
||||
pub audio_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>)> {
|
||||
@@ -63,6 +72,7 @@ pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
|
||||
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<Config>, Option<PathBuf>)> {
|
||||
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()))?;
|
||||
|
||||
+27
@@ -59,6 +59,10 @@ pub struct Item {
|
||||
pub runtime_ticks: Option<i64>,
|
||||
#[serde(rename = "SeriesName", default)]
|
||||
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)]
|
||||
pub index_number: Option<i32>,
|
||||
#[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<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 {
|
||||
format!(
|
||||
"{}/Videos/{}/stream?Static=true&api_key={}&DeviceId={}",
|
||||
|
||||
@@ -139,6 +139,7 @@ fn run<B: ratatui::backend::Backend>(
|
||||
password: form.password.clone(),
|
||||
audio_lang: None,
|
||||
subtitle_lang: None,
|
||||
video_output: None,
|
||||
});
|
||||
}
|
||||
KeyCode::Char(' ') if form.focus == Field::SaveToggle => {
|
||||
|
||||
+6
-2
@@ -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?;
|
||||
|
||||
+70
-15
@@ -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<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}"))?;
|
||||
// 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<PlaybackOutcome> {
|
||||
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<PlaybackOutcome>;
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -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<String>,
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<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) {
|
||||
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<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> {
|
||||
|
||||
Reference in new issue
Block a user