From 19523a3e2b45c0bec56c3651464486fe11384054 Mon Sep 17 00:00:00 2001 From: cwj3rcb Date: Sat, 14 Feb 2026 04:37:21 -0600 Subject: [PATCH] full streaming playback complete --- src/main.rs | 246 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 194 insertions(+), 52 deletions(-) diff --git a/src/main.rs b/src/main.rs index 10b2a0a..3a0d8b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,16 @@ mod core; const PAGE_SIZE: usize = 50; use std::{ - io, + io::{self, Write}, + path::PathBuf, + process::{Child, Command, Stdio}, time::{Duration, Instant}, - process::Command, + thread, }; +use std::os::unix::net::UnixStream; + + use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, execute, @@ -132,17 +137,15 @@ struct App { login_error: Option, search: Option, - pending_play: Option, + mpv: Option, } -#[derive(Clone, Debug)] -struct PlayRequest { - item_id: String, - item_type: Option, - name: String, +#[derive(Debug)] +struct MpvSession { + socket_path: PathBuf, + child: Child, } - impl Default for App { fn default() -> Self { let loaded = core::config::load_config().ok(); @@ -188,8 +191,7 @@ impl Default for App { login_focus: LoginFocus::User, login_error: None, search: None, - pending_play: None, - + mpv: None, } } } @@ -236,28 +238,11 @@ fn run_app(terminal: &mut Terminal>) -> io::Result< } } - // If we need to play something, suspend the TUI and run mpv - if let Some(req) = app.pending_play.take() { - // Restore terminal to normal before launching mpv - disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; - terminal.show_cursor()?; - - // Run mpv (blocking until it exits) - let result = match &app.config { - Some(cfg) => play_in_mpv(cfg, &req).map_err(|e| e), - None => Err("No config loaded".to_string()), - }; - - // Re-enter TUI mode after mpv closes - enable_raw_mode()?; - execute!(terminal.backend_mut(), EnterAlternateScreen)?; - terminal.clear()?; - - // Update status - match result { - Ok(_) => app.status = format!("Finished: {}", req.name), - Err(e) => app.status = format!("Play failed: {}", e), + if let Some(mpv) = &mut app.mpv { + if let Ok(Some(status)) = mpv.child.try_wait() { + app.status = format!("mpv exited: {status}"); + let _ = std::fs::remove_file(&mpv.socket_path); + app.mpv = None; } } @@ -414,6 +399,94 @@ fn handle_key(app: &mut App, code: KeyCode) { } } + // q behavior: if not home, go home; if home, quit + if matches!(code, KeyCode::Char('q')) { + if !matches!(app.current_view(), View::Categories) { + app.view_stack = vec![View::Categories]; + app.selected = 0; + app.status = "Home".into(); + return; + } else { + app.should_exit = true; + return; + } + } + + // mpv controls (only when not typing into search/login) + if app.search.is_none() && !matches!(app.current_view(), View::Login) { + match code { + KeyCode::Char('a') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_audio(mpv) { + app.status = format!("mpv audio: {e}"); + } + } + return; + } + KeyCode::Char('s') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_cycle_subtitles(mpv) { + app.status = format!("mpv subs: {e}"); + } + } + return; + } + KeyCode::Char('v') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_subs(mpv) { + app.status = format!("mpv sub vis: {e}"); + } + } + return; + } + KeyCode::Char('x') => { + if let Some(mut mpv) = app.mpv.take() { + mpv_stop(&mut mpv); + app.status = "Stopped mpv".into(); + } + return; + } + KeyCode::Char('f') => { + if let Some(mpv) = &app.mpv { + if let Err(e) = mpv_toggle_fullscreen(mpv) { + app.status = format!("mpv fullscreen: {e}"); + } + } else { + app.status = "mpv not running (press 'p' to play)".into(); + } + return; + } + KeyCode::Char('p') => { + // p = play selected item (spawn or replace) + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + // Determine what is “selected” depending on view + let item_id = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => items[*selected].id.clone(), + _ => { app.status = "Nothing to play here".into(); return; } + }; + + // If mpv running -> replace, else spawn + if let Some(mpv) = &app.mpv { + match mpv_load_item(&cfg, mpv, &item_id) { + Ok(_) => app.status = "mpv: loaded".into(), + Err(e) => app.status = format!("mpv load failed: {e}"), + } + } else { + match spawn_mpv(&cfg, &item_id) { + Ok(mpv) => { app.mpv = Some(mpv); app.status = "mpv: playing".into(); } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + _ => {} + } + } + match code { KeyCode::Char('q') => app.should_exit = true, @@ -519,14 +592,9 @@ fn handle_key(app: &mut App, code: KeyCode) { } // Playables -> mpv + // Playables: do nothing on Enter (use 'p' to play) if ty == "Episode" || ty == "Audio" || ty == "Movie" { - let name = it.name.clone(); - app.pending_play = Some(PlayRequest { - item_id: it.id.clone(), - item_type: it.item_type.clone(), - name: name.clone(), - }); - app.status = format!("Launching mpv: {}", name); + app.status = "Press 'p' to play".into(); return; } @@ -966,32 +1034,106 @@ fn open_children(app: &mut App, title: String, query: core::jellyfin::LibraryQue } } -fn play_in_mpv(cfg: &core::config::Config, req: &PlayRequest) -> Result<(), String> { +fn mpv_socket_path() -> PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + p.push(format!("cj-jftui-mpv-{}.sock", pid)); + p +} + +fn mpv_send(socket: &PathBuf, json_line: &str) -> Result<(), String> { + let mut stream = UnixStream::connect(socket) + .map_err(|e| format!("mpv ipc connect failed: {e}"))?; + stream + .write_all(json_line.as_bytes()) + .map_err(|e| format!("mpv ipc write failed: {e}"))?; + Ok(()) +} + +fn mpv_cmd(socket: &PathBuf, cmd: &str) -> Result<(), String> { + // cmd should already be valid JSON without trailing newline + mpv_send(socket, &format!("{cmd}\n")) +} + +fn spawn_mpv(cfg: &core::config::Config, first_item_id: &str) -> Result { let token = cfg .access_token .as_deref() .ok_or_else(|| "Missing access_token (login again)".to_string())?; - // Simple “download” endpoint works well for mpv. - // (Jellyfin supports this route; it returns the media file/stream.) let base = cfg.base_url.trim_end_matches('/'); - let url = format!("{}/Items/{}/Download", base, req.item_id); + let url = format!("{}/Items/{}/Download", base, first_item_id); - // mpv can send HTTP headers; Jellyfin accepts X-Emby-Token let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); - let status = Command::new("mpv") + // If a stale socket exists, remove it. + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") .arg(url) .arg("--force-window=yes") - .arg(format!("--http-header-fields={}", header)) - .status() - .map_err(|e| format!("Failed to launch mpv: {e}"))?; + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; - if !status.success() { - return Err(format!("mpv exited with status: {status}")); + // Wait briefly for IPC socket to appear + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { + socket_path: sock, + child, + }); + } + thread::sleep(Duration::from_millis(10)); } - Ok(()) + // If socket never appeared, mpv probably died + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + + +fn mpv_load_item(cfg: &core::config::Config, mpv: &MpvSession, item_id: &str) -> Result<(), String> { + let base = cfg.base_url.trim_end_matches('/'); + let url = format!("{}/Items/{}/Download", base, item_id); + + // loadfile replace + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + +fn mpv_cycle_audio(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","audio"]}"#) +} + +fn mpv_cycle_subtitles(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub"]}"#) +} + +fn mpv_toggle_subs(mpv: &MpvSession) -> Result<(), String> { + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","sub-visibility"]}"#) +} + +fn mpv_stop(mpv: &mut MpvSession) { + let _ = mpv_cmd(&mpv.socket_path, r#"{"command":["quit"]}"#); + let _ = mpv.child.kill(); + let _ = std::fs::remove_file(&mpv.socket_path); +} + +fn mpv_toggle_fullscreen(mpv: &MpvSession) -> Result<(), String> { + // cycles fullscreen on/off + mpv_cmd(&mpv.socket_path, r#"{"command":["cycle","fullscreen"]}"#) + // alternatively: + // mpv_cmd(&mpv.socket_path, r#"{"command":["set_property","fullscreen","yes"]}"#) } struct Panes {