full streaming playback complete
This commit is contained in:
246
src/main.rs
246
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<String>,
|
||||
|
||||
search: Option<SearchState>,
|
||||
pending_play: Option<PlayRequest>,
|
||||
mpv: Option<MpvSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlayRequest {
|
||||
item_id: String,
|
||||
item_type: Option<String>,
|
||||
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<CrosstermBackend<io::Stdout>>) -> 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<MpvSession, String> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user