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`.
|
# 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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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={}",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||||
}
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
Reference in new issue
Block a user