full streaming playback complete

This commit is contained in:
2026-02-14 04:37:21 -06:00
parent 5144af1cfb
commit 19523a3e2b

View File

@@ -3,11 +3,16 @@ mod core;
const PAGE_SIZE: usize = 50; const PAGE_SIZE: usize = 50;
use std::{ use std::{
io, io::{self, Write},
path::PathBuf,
process::{Child, Command, Stdio},
time::{Duration, Instant}, time::{Duration, Instant},
process::Command, thread,
}; };
use std::os::unix::net::UnixStream;
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode, KeyEventKind}, event::{self, Event, KeyCode, KeyEventKind},
execute, execute,
@@ -132,17 +137,15 @@ struct App {
login_error: Option<String>, login_error: Option<String>,
search: Option<SearchState>, search: Option<SearchState>,
pending_play: Option<PlayRequest>, mpv: Option<MpvSession>,
} }
#[derive(Clone, Debug)] #[derive(Debug)]
struct PlayRequest { struct MpvSession {
item_id: String, socket_path: PathBuf,
item_type: Option<String>, child: Child,
name: String,
} }
impl Default for App { impl Default for App {
fn default() -> Self { fn default() -> Self {
let loaded = core::config::load_config().ok(); let loaded = core::config::load_config().ok();
@@ -188,8 +191,7 @@ impl Default for App {
login_focus: LoginFocus::User, login_focus: LoginFocus::User,
login_error: None, login_error: None,
search: None, search: None,
pending_play: None, mpv: None,
} }
} }
} }
@@ -236,28 +238,11 @@ fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<
} }
} }
// If we need to play something, suspend the TUI and run mpv if let Some(mpv) = &mut app.mpv {
if let Some(req) = app.pending_play.take() { if let Ok(Some(status)) = mpv.child.try_wait() {
// Restore terminal to normal before launching mpv app.status = format!("mpv exited: {status}");
disable_raw_mode()?; let _ = std::fs::remove_file(&mpv.socket_path);
execute!(terminal.backend_mut(), LeaveAlternateScreen)?; app.mpv = None;
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),
} }
} }
@@ -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 { match code {
KeyCode::Char('q') => app.should_exit = true, KeyCode::Char('q') => app.should_exit = true,
@@ -519,14 +592,9 @@ fn handle_key(app: &mut App, code: KeyCode) {
} }
// Playables -> mpv // Playables -> mpv
// Playables: do nothing on Enter (use 'p' to play)
if ty == "Episode" || ty == "Audio" || ty == "Movie" { if ty == "Episode" || ty == "Audio" || ty == "Movie" {
let name = it.name.clone(); app.status = "Press 'p' to play".into();
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);
return; 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<MpvSession, String> {
let token = cfg let token = cfg
.access_token .access_token
.as_deref() .as_deref()
.ok_or_else(|| "Missing access_token (login again)".to_string())?; .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 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 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(url)
.arg("--force-window=yes") .arg("--force-window=yes")
.arg(format!("--http-header-fields={}", header)) .arg("--keep-open=yes")
.status() .arg("--idle=yes")
.map_err(|e| format!("Failed to launch mpv: {e}"))?; .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() { // Wait briefly for IPC socket to appear
return Err(format!("mpv exited with status: {status}")); 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 { struct Panes {