diff --git a/jellyfin-kiosk-rs/Cargo.lock b/jellyfin-kiosk-rs/Cargo.lock index aadce0e..f2e295c 100644 --- a/jellyfin-kiosk-rs/Cargo.lock +++ b/jellyfin-kiosk-rs/Cargo.lock @@ -1466,6 +1466,8 @@ dependencies = [ "eframe", "egui_extras", "image", + "libc", + "libmpv-sys", "raw-window-handle", "reqwest", "rumqttc", @@ -1595,6 +1597,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libmpv-sys" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0df938d3145cd8f134572721a27afa3a51f9bc1c26ae30a1d5077162f96d074b" + [[package]] name = "libredox" version = "0.1.25" diff --git a/jellyfin-kiosk-rs/Cargo.toml b/jellyfin-kiosk-rs/Cargo.toml index 7ff83f8..91da08d 100644 --- a/jellyfin-kiosk-rs/Cargo.toml +++ b/jellyfin-kiosk-rs/Cargo.toml @@ -17,3 +17,5 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } raw-window-handle = "0.6" rumqttc = { version = "0.25", default-features = false } +libc = "0.2" +libmpv-sys = { version = "3.1", default-features = false } diff --git a/jellyfin-kiosk-rs/build.rs b/jellyfin-kiosk-rs/build.rs new file mode 100644 index 0000000..a8c4e72 --- /dev/null +++ b/jellyfin-kiosk-rs/build.rs @@ -0,0 +1,31 @@ +use std::path::Path; + +fn main() { + let out_dir = std::env::var("OUT_DIR").unwrap(); + let lib_dir = Path::new(&out_dir).join("libmpv_link"); + let _ = std::fs::create_dir_all(&lib_dir); + + let candidates = [ + "/lib/x86_64-linux-gnu/libmpv.so.2", + "/usr/lib/x86_64-linux-gnu/libmpv.so.2", + "/usr/lib64/libmpv.so.2", + "/lib/x86_64-linux-gnu/libmpv.so.1", + "/usr/lib/x86_64-linux-gnu/libmpv.so.1", + "/usr/lib/libmpv.so.2", + "/usr/lib/libmpv.so.1", + ]; + + let dest = lib_dir.join("libmpv.so"); + for candidate in &candidates { + if Path::new(candidate).exists() { + let _ = std::fs::remove_file(&dest); + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(candidate, &dest); + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + break; + } + } + + println!("cargo:rustc-link-lib=mpv"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/jellyfin-kiosk-rs/jellyfin-kiosk b/jellyfin-kiosk-rs/jellyfin-kiosk index e32b58b..fa132f9 100755 Binary files a/jellyfin-kiosk-rs/jellyfin-kiosk and b/jellyfin-kiosk-rs/jellyfin-kiosk differ diff --git a/jellyfin-kiosk-rs/src/app.rs b/jellyfin-kiosk-rs/src/app.rs index 2fac419..e5ae1e4 100644 --- a/jellyfin-kiosk-rs/src/app.rs +++ b/jellyfin-kiosk-rs/src/app.rs @@ -184,8 +184,8 @@ pub struct KioskApp { event_rx: UnboundedReceiver, pub image_cache: crate::image_cache::AsyncImageCache, egui_ctx: Option, - window_id: Option, - x11_overlay: Option, + _window_id: Option, + last_playing_title: String, } impl KioskApp { @@ -269,8 +269,8 @@ impl KioskApp { event_rx, image_cache, egui_ctx: None, - window_id, - x11_overlay: crate::x11_overlay::X11Overlay::try_new(), + _window_id: window_id, + last_playing_title: String::new(), } } @@ -423,6 +423,9 @@ impl KioskApp { return; } if item.item_type == "YouTubeVideo" || item.id.starts_with("http") { + self.view_mode = ViewMode::Player; + self.playback_osd_open = true; + self.playback_osd_idx = 2; let player = self.player.clone(); let url = item.id.clone(); let name = item.name.clone(); @@ -1075,6 +1078,9 @@ impl KioskApp { if self.selected_grid_idx == 0 { // Play first track or all tracks if let Some(first_track) = self.folder_items.first() { + self.view_mode = ViewMode::Player; + self.playback_osd_open = true; + self.playback_osd_idx = 2; let client = self.client.clone(); let player = self.player.clone(); let item = first_track.clone(); @@ -1087,6 +1093,9 @@ impl KioskApp { } else { let track_idx = self.selected_grid_idx - 1; if let Some(track) = self.folder_items.get(track_idx) { + self.view_mode = ViewMode::Player; + self.playback_osd_open = true; + self.playback_osd_idx = 2; let client = self.client.clone(); let player = self.player.clone(); let item = track.clone(); @@ -1189,9 +1198,14 @@ impl KioskApp { } impl eframe::App for KioskApp { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.egui_ctx = Some(ctx.clone()); + // Initialize libmpv OpenGL render context when GL context is active + if frame.gl().is_some() { + self.player.ensure_render_context(ctx); + } + // Update async image cache textures self.image_cache.update(ctx); @@ -1314,7 +1328,7 @@ impl eframe::App for KioskApp { } // Check player status - let (is_playing, is_audio, current_vol, is_muted) = if let Ok(st) = self.player.status.try_lock() { + let (is_playing, _is_audio, current_vol, is_muted, current_title) = if let Ok(st) = self.player.status.try_lock() { if st.error_message != self.player_error_msg { if let Some(ref em) = st.error_message { self.active_error_dialog = Some(em.clone()); @@ -1322,10 +1336,11 @@ impl eframe::App for KioskApp { self.player_error_msg = st.error_message.clone(); } let is_aud = st.media_type == "audio" - || (st.media_type != "video" && st.media_type != "stream" && st.duration > 0.0 && !st.artist.is_empty()); - (st.state != PlaybackState::Stopped, is_aud, st.volume, st.is_muted) + || st.media_type == "music" + || (st.media_type != "video" && st.media_type != "stream" && (!st.artist.is_empty() || st.duration > 0.0)); + (st.state != PlaybackState::Stopped, is_aud, st.volume, st.is_muted, st.title.clone()) } else { - (false, false, self.last_seen_volume, false) + (false, false, self.last_seen_volume, false, String::new()) }; if (current_vol - self.last_seen_volume).abs() > 0.5 { @@ -1333,46 +1348,19 @@ impl eframe::App for KioskApp { self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); } - if is_playing && !self.was_playing { + let is_new_item = !current_title.is_empty() && current_title != self.last_playing_title; + if is_playing && (!self.was_playing || is_new_item) { self.view_mode = ViewMode::Player; self.playback_osd_open = true; self.playback_osd_idx = 2; // Pause/Play - if let Some(ref overlay) = self.x11_overlay { - overlay.reset_cache(); - } + self.last_playing_title = current_title; } else if !is_playing && self.was_playing { self.view_mode = ViewMode::Browser; self.playback_osd_open = false; + self.last_playing_title.clear(); } self.was_playing = is_playing; - // Synchronize embedded MPV child window geometry & visibility - if let (Some(ref overlay), Some(wid)) = (&self.x11_overlay, self.window_id) { - let screen_rect = ctx.screen_rect(); - let screen_w = screen_rect.width() as u32; - let screen_h = screen_rect.height() as u32; - - if !is_playing { - overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); - overlay.sync_mute_badge(wid, screen_w, false); - } else if self.view_mode == ViewMode::Browser { - overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); - overlay.sync_mute_badge(wid, screen_w, false); - } else if is_audio { - overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); - overlay.sync_mute_badge(wid, screen_w, is_muted); - } else if self.playback_osd_open { - let top_margin = 48i32; - let bottom_bar_h = 142u32; - let video_h = screen_h.saturating_sub((top_margin as u32) + bottom_bar_h); - overlay.set_geometry(wid, 0, top_margin, screen_w, video_h, true); - overlay.sync_mute_badge(wid, screen_w, is_muted); - } else { - overlay.set_geometry(wid, 0, 0, screen_w, screen_h, true); - overlay.sync_mute_badge(wid, screen_w, is_muted); - } - } - if is_playing || self.volume_hud_until.is_some() || self.osd_notice.is_some() || self.folder_loading { ctx.request_repaint_after(Duration::from_millis(100)); } @@ -1575,10 +1563,10 @@ impl eframe::App for KioskApp { ("video".to_string(), "Media afspelen".to_string(), String::new(), String::new(), 0.0, 0.0, false) }; - let is_audio = media_type == "audio" || (media_type != "video" && media_type != "stream" && duration > 0.0 && !artist.is_empty()); + let is_audio = media_type == "audio" || media_type == "music" || (media_type != "video" && media_type != "stream" && (!artist.is_empty() || duration > 0.0)); - // Panel background: solid dark slate for music player; translucent dark vignette for video OSD; transparent when video is playing without OSD - let panel_fill = if is_audio || self.playback_osd_open { + // Panel background: solid dark slate for music player; transparent for video so MPV renders directly into OpenGL + let panel_fill = if is_audio { Color32::from_rgb(10, 14, 20) } else { Color32::TRANSPARENT @@ -1591,14 +1579,47 @@ impl eframe::App for KioskApp { let screen_w = screen_rect.width(); let screen_h = screen_rect.height(); + // 1. Paint MPV video frame directly into the OpenGL framebuffer + if !is_audio { + if let Some(rc) = self.player.render_context() { + let callback = eframe::egui_glow::CallbackFn::new(move |info, _painter| { + let [w, h] = info.screen_size_px; + let mut fbo = libmpv_sys::mpv_opengl_fbo { + fbo: 0, + w: w as i32, + h: h as i32, + internal_format: 0, + }; + let mut flip_y: i32 = 1; + let mut params = [ + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO, + data: &mut fbo as *mut _ as *mut _, + }, + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y, + data: &mut flip_y as *mut _ as *mut _, + }, + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_INVALID, + data: std::ptr::null_mut(), + }, + ]; + unsafe { + rc.render(&mut params); + rc.report_swap(); + } + }); + ui.painter().add(egui::PaintCallback { + rect: screen_rect, + callback: Arc::new(callback), + }); + } + } + // Mouse click on video area toggles playback OSD menu if !is_audio { - let video_click_rect = if self.playback_osd_open { - Rect::from_min_max(Pos2::new(0.0, 48.0), Pos2::new(screen_w, screen_h - 142.0)) - } else { - screen_rect - }; - let video_resp = ui.interact(video_click_rect, ui.id().with("video_osd_toggle"), egui::Sense::click()); + let video_resp = ui.interact(screen_rect, ui.id().with("video_osd_toggle"), egui::Sense::click()); if video_resp.clicked() { self.playback_osd_open = !self.playback_osd_open; if self.playback_osd_open { @@ -2848,6 +2869,9 @@ impl eframe::App for KioskApp { if let Some(t_idx) = clicked_track_idx { self.selected_grid_idx = t_idx + 1; + self.view_mode = ViewMode::Player; + self.playback_osd_open = true; + self.playback_osd_idx = 2; if let Some(track) = self.folder_items.get(t_idx) { let client = self.client.clone(); let player = self.player.clone(); diff --git a/jellyfin-kiosk-rs/src/config.rs b/jellyfin-kiosk-rs/src/config.rs index 074b4a1..6a9480f 100644 --- a/jellyfin-kiosk-rs/src/config.rs +++ b/jellyfin-kiosk-rs/src/config.rs @@ -38,6 +38,7 @@ fn default_device_id() -> String { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct PlayerConfig { #[serde(default = "default_mpv_path")] pub mpv_path: String, @@ -85,6 +86,8 @@ pub struct KodiRpcConfig { pub enabled: bool, #[serde(default = "default_http_port")] pub http_port: u16, + #[serde(default = "default_ws_port")] + pub ws_port: u16, } fn default_true() -> bool { @@ -93,12 +96,16 @@ fn default_true() -> bool { fn default_http_port() -> u16 { 8080 } +fn default_ws_port() -> u16 { + 9090 +} impl Default for KodiRpcConfig { fn default() -> Self { Self { enabled: default_true(), http_port: default_http_port(), + ws_port: default_ws_port(), } } } diff --git a/jellyfin-kiosk-rs/src/eventserver.rs b/jellyfin-kiosk-rs/src/eventserver.rs index 01828c9..a67fa8f 100644 --- a/jellyfin-kiosk-rs/src/eventserver.rs +++ b/jellyfin-kiosk-rs/src/eventserver.rs @@ -287,31 +287,23 @@ pub fn dispatch_button_or_code( } "audionextlanguage" | "nextaudio" | "cycleaudio" => { let _ = nav_tx.send(RemoteNavAction::CycleAudio); - let p = player.clone(); - tokio::spawn(async move { p.cycle_audio().await; }); } "play" | "pause" | "playpause" => { - let _ = nav_tx.send(RemoteNavAction::PlayPause); let p = player.clone(); tokio::spawn(async move { p.play_pause().await; }); } "stop" => { let _ = nav_tx.send(RemoteNavAction::Stop); - let p = player.clone(); - tokio::spawn(async move { p.stop().await; }); } "volumeup" | "vol_up" => { - let _ = nav_tx.send(RemoteNavAction::VolumeUp); let p = player.clone(); tokio::spawn(async move { p.volume_up().await; }); } "volumedown" | "vol_down" => { - let _ = nav_tx.send(RemoteNavAction::VolumeDown); let p = player.clone(); tokio::spawn(async move { p.volume_down().await; }); } "mute" | "volumemute" | "togglemute" => { - let _ = nav_tx.send(RemoteNavAction::ToggleMute); let p = player.clone(); tokio::spawn(async move { p.toggle_mute().await; }); } @@ -333,20 +325,16 @@ pub fn dispatch_button_or_code( } 0xF021 => { let _ = nav_tx.send(RemoteNavAction::Stop); - let p = player.clone(); - tokio::spawn(async move { p.stop().await; }); } 0xF02B => { let p = player.clone(); tokio::spawn(async move { p.toggle_mute().await; }); } 0xF02C => { - let _ = nav_tx.send(RemoteNavAction::VolumeDown); let p = player.clone(); tokio::spawn(async move { p.volume_down().await; }); } 0xF02D => { - let _ = nav_tx.send(RemoteNavAction::VolumeUp); let p = player.clone(); tokio::spawn(async move { p.volume_up().await; }); } @@ -437,31 +425,23 @@ pub fn dispatch_action( } "audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => { let _ = nav_tx.send(RemoteNavAction::CycleAudio); - let p = player.clone(); - tokio::spawn(async move { p.cycle_audio().await; }); } "play" | "pause" | "playpause" | "15" => { - let _ = nav_tx.send(RemoteNavAction::PlayPause); let p = player.clone(); tokio::spawn(async move { p.play_pause().await; }); } "stop" | "14" => { let _ = nav_tx.send(RemoteNavAction::Stop); - let p = player.clone(); - tokio::spawn(async move { p.stop().await; }); } "volumeup" | "vol_up" | "88" => { - let _ = nav_tx.send(RemoteNavAction::VolumeUp); let p = player.clone(); tokio::spawn(async move { p.volume_up().await; }); } "volumedown" | "vol_down" | "89" => { - let _ = nav_tx.send(RemoteNavAction::VolumeDown); let p = player.clone(); tokio::spawn(async move { p.volume_down().await; }); } "mute" | "togglemute" | "volumemute" | "13" => { - let _ = nav_tx.send(RemoteNavAction::ToggleMute); let p = player.clone(); tokio::spawn(async move { p.toggle_mute().await; }); } diff --git a/jellyfin-kiosk-rs/src/main.rs b/jellyfin-kiosk-rs/src/main.rs index 23d6e08..e542d9d 100644 --- a/jellyfin-kiosk-rs/src/main.rs +++ b/jellyfin-kiosk-rs/src/main.rs @@ -9,7 +9,6 @@ mod image_cache; mod jellyfin; mod mqtt; mod player; -mod x11_overlay; mod yatse; mod youtube; @@ -47,7 +46,7 @@ async fn main() -> Result<(), Box> { player: player.clone(), nav_tx: nav_tx.clone(), }; - let app_router = create_yatse_router(yatse_state); + let app_router = create_yatse_router(yatse_state.clone()); let port = config.kodi_rpc.http_port; tokio::spawn(async move { @@ -58,6 +57,10 @@ async fn main() -> Result<(), Box> { } }); + // Start Kodi TCP JSON-RPC socket server on port 9090 for Yatse + let ws_port = config.kodi_rpc.ws_port; + crate::yatse::start_tcp_jsonrpc_server(ws_port, yatse_state).await; + // Start Kodi EventServer (UDP 9777) for Yatse let event_server = KodiEventServer::new(9777, player.clone(), nav_tx); let _ = event_server.start().await; diff --git a/jellyfin-kiosk-rs/src/player.rs b/jellyfin-kiosk-rs/src/player.rs index a51ffae..5ed90f8 100644 --- a/jellyfin-kiosk-rs/src/player.rs +++ b/jellyfin-kiosk-rs/src/player.rs @@ -1,8 +1,7 @@ use serde::{Deserialize, Serialize}; -use std::process::{Child, Command}; +use std::ffi::{CStr, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; use tokio::sync::Mutex; use crate::config::PlayerConfig; @@ -47,28 +46,321 @@ impl Default for PlayerStatus { } } +#[derive(Clone, Copy)] +pub struct MpvHandle(pub *mut libmpv_sys::mpv_handle); +unsafe impl Send for MpvHandle {} +unsafe impl Sync for MpvHandle {} + +impl MpvHandle { + pub fn cmd(&self, args: &[&str]) { + if self.0.is_null() { + return; + } + let c_strings: Vec = args.iter().filter_map(|s| CString::new(*s).ok()).collect(); + let mut ptrs: Vec<*const std::ffi::c_char> = c_strings.iter().map(|c| c.as_ptr()).collect(); + ptrs.push(std::ptr::null()); + unsafe { + libmpv_sys::mpv_command(self.0, ptrs.as_mut_ptr()); + } + } + + pub fn set_option_string(&self, name: &str, value: &str) { + if self.0.is_null() { + return; + } + if let (Ok(n), Ok(v)) = (CString::new(name), CString::new(value)) { + unsafe { + libmpv_sys::mpv_set_option_string(self.0, n.as_ptr(), v.as_ptr()); + } + } + } +} + +#[derive(Clone, Copy)] +pub struct MpvRenderContext(pub *mut libmpv_sys::mpv_render_context); +unsafe impl Send for MpvRenderContext {} +unsafe impl Sync for MpvRenderContext {} + +impl MpvRenderContext { + pub unsafe fn render(&self, params: &mut [libmpv_sys::mpv_render_param]) { + if !self.0.is_null() { + libmpv_sys::mpv_render_context_render(self.0, params.as_mut_ptr()); + } + } + + pub unsafe fn report_swap(&self) { + if !self.0.is_null() { + libmpv_sys::mpv_render_context_report_swap(self.0); + } + } +} + +type GetProcAddressFn = unsafe extern "C" fn(name: *const u8) -> *mut std::ffi::c_void; + +unsafe extern "C" fn get_proc_address_cb( + _ctx: *mut std::ffi::c_void, + name: *const std::ffi::c_char, +) -> *mut std::ffi::c_void { + if name.is_null() { + return std::ptr::null_mut(); + } + // 1. Try glXGetProcAddressARB + let glx_name = b"glXGetProcAddressARB\0"; + let glx_ptr = libc::dlsym(std::ptr::null_mut(), glx_name.as_ptr() as *const _); + if !glx_ptr.is_null() { + let glx_fn: GetProcAddressFn = std::mem::transmute(glx_ptr); + let ptr = glx_fn(name as *const u8); + if !ptr.is_null() { + return ptr; + } + } + // 2. Try eglGetProcAddress + let egl_name = b"eglGetProcAddress\0"; + let egl_ptr = libc::dlsym(std::ptr::null_mut(), egl_name.as_ptr() as *const _); + if !egl_ptr.is_null() { + let egl_fn: GetProcAddressFn = std::mem::transmute(egl_ptr); + let ptr = egl_fn(name as *const u8); + if !ptr.is_null() { + return ptr; + } + } + // 3. Fallback: dlsym + libc::dlsym(std::ptr::null_mut(), name) +} + +unsafe extern "C" fn on_mpv_render_update(ctx_ptr: *mut std::ffi::c_void) { + if !ctx_ptr.is_null() { + let egui_ctx = &*(ctx_ptr as *const eframe::egui::Context); + egui_ctx.request_repaint(); + } +} + +enum InternalMpvEvent { + StartFile, + FileLoaded, + TimePos(f64), + Duration(f64), + Pause(bool), + Volume(f64), + Mute(bool), + MediaTitle(String), + IcyTitle(String), + Metadata { + artist: Option, + genre: Option, + stream_title: Option, + }, + EndFile { + reason: i32, + error: i32, + }, +} + pub struct PlayerController { - config: PlayerConfig, - child_process: Mutex>, + _config: PlayerConfig, + pub mpv: MpvHandle, + pub render_ctx: Arc>>, pub status: Arc>, status_tx: tokio::sync::watch::Sender, status_rx: tokio::sync::watch::Receiver, pub window_id: Mutex>, - pub ipc_tx: Mutex>>, + is_render_initialized: AtomicBool, + is_running: Arc, + event_thread: std::sync::Mutex>>, } impl PlayerController { pub fn new(config: PlayerConfig) -> Arc { let initial_status = PlayerStatus::default(); let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone()); + + let raw_mpv = unsafe { libmpv_sys::mpv_create() }; + if raw_mpv.is_null() { + tracing::error!("Failed to create libmpv instance!"); + } + + let mpv = MpvHandle(raw_mpv); + + // Configure MPV options + mpv.set_option_string("vo", "libmpv"); + mpv.set_option_string("hwdec", &config.hwdec); + mpv.set_option_string("idle", "yes"); + mpv.set_option_string("keep-open", "yes"); + mpv.set_option_string("osd-bar", "no"); + mpv.set_option_string("osd-level", "0"); + mpv.set_option_string("ytdl", "yes"); + + let ytdlp_path = if std::path::Path::new("/usr/local/bin/yt-dlp").exists() { + "/usr/local/bin/yt-dlp" + } else if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() { + "/home/bart/.local/bin/yt-dlp" + } else { + "yt-dlp" + }; + mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path)); + mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best"); + mpv.set_option_string("ytdl-raw-options", "no-playlist="); + + let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) }; + if init_res != 0 { + tracing::warn!("mpv_initialize returned error code: {}", init_res); + } else { + tracing::info!("libmpv initialized successfully (vo=libmpv, hwdec={})", config.hwdec); + } + + // Observe properties + unsafe { + libmpv_sys::mpv_observe_property(raw_mpv, 1, b"time-pos\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE); + libmpv_sys::mpv_observe_property(raw_mpv, 2, b"duration\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE); + libmpv_sys::mpv_observe_property(raw_mpv, 3, b"pause\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG); + libmpv_sys::mpv_observe_property(raw_mpv, 4, b"volume\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE); + libmpv_sys::mpv_observe_property(raw_mpv, 5, b"media-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING); + libmpv_sys::mpv_observe_property(raw_mpv, 6, b"icy-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING); + libmpv_sys::mpv_observe_property(raw_mpv, 7, b"mute\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG); + libmpv_sys::mpv_observe_property(raw_mpv, 8, b"metadata\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_NODE); + } + + let status_arc = Arc::new(Mutex::new(initial_status)); + let render_ctx = Arc::new(std::sync::Mutex::new(None)); + let is_running = Arc::new(AtomicBool::new(true)); + + // Create internal event channel to decouple MPV C thread from Tokio async tasks + let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Spawn Tokio consumer task + let status_consumer = status_arc.clone(); + let status_tx_consumer = status_tx.clone(); + tokio::spawn(async move { + while let Some(event) = ev_rx.recv().await { + let mut st = status_consumer.lock().await; + let mut notify = false; + match event { + InternalMpvEvent::StartFile | InternalMpvEvent::FileLoaded => { + st.state = PlaybackState::Playing; + st.error_message = None; + notify = true; + } + InternalMpvEvent::TimePos(pos) => { + st.time_pos = pos; + } + InternalMpvEvent::Duration(dur) => { + st.duration = dur; + } + InternalMpvEvent::Pause(paused) => { + let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing }; + if st.state != new_state && st.state != PlaybackState::Stopped { + st.state = new_state; + notify = true; + } + } + InternalMpvEvent::Volume(vol) => { + if (st.volume - vol).abs() > 0.01 { + st.volume = vol; + notify = true; + } + } + InternalMpvEvent::Mute(m) => { + if st.is_muted != m { + st.is_muted = m; + notify = true; + } + } + InternalMpvEvent::MediaTitle(mt) => { + if st.media_type == "stream" { + if st.stream_title.is_empty() || st.stream_title != mt { + st.stream_title = mt; + notify = true; + } + } else if st.title.is_empty() { + st.title = mt; + notify = true; + } + } + InternalMpvEvent::IcyTitle(icy) => { + if st.stream_title != icy { + st.stream_title = icy; + st.media_type = "stream".to_string(); + notify = true; + } + } + InternalMpvEvent::Metadata { artist, genre, stream_title } => { + if let Some(a) = artist { + if st.artist != a { + st.artist = a; + notify = true; + } + } + if let Some(g) = genre { + if st.genre != g { + st.genre = g; + notify = true; + } + } + if let Some(s) = stream_title { + if st.stream_title != s { + st.stream_title = s; + st.media_type = "stream".to_string(); + notify = true; + } + } + } + InternalMpvEvent::EndFile { reason, error } => { + if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 { + let err_msg = if error != 0 { + let msg = unsafe { + let p = libmpv_sys::mpv_error_string(error); + if !p.is_null() { + CStr::from_ptr(p).to_str().unwrap_or("Afspeelfout") + } else { + "Afspeelfout" + } + }; + format!("Afspeelfout: {}", msg) + } else { + "Afspeelfout".to_string() + }; + tracing::warn!("MPV playback error: {}", err_msg); + st.state = PlaybackState::Stopped; + st.error_message = Some(err_msg); + notify = true; + } else if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32 { + st.state = PlaybackState::Stopped; + st.time_pos = 0.0; + st.media_type.clear(); + st.title.clear(); + st.artist.clear(); + st.genre.clear(); + st.stream_title.clear(); + notify = true; + } + // Note: If reason == STOP (2) or REDIRECT (5), this was triggered by loadfile replacing + // the previous file, so we intentionally keep the new file playing! + } + } + if notify { + let _ = status_tx_consumer.send(st.clone()); + } + } + }); + + // Spawn background event thread for MPV + let mpv_for_thread = mpv; + let is_running_for_thread = is_running.clone(); + let handle = std::thread::spawn(move || { + run_mpv_event_loop(mpv_for_thread, is_running_for_thread, ev_tx); + }); + Arc::new(Self { - config, - child_process: Mutex::new(None), - status: Arc::new(Mutex::new(initial_status)), + _config: config, + mpv, + render_ctx, + status: status_arc, status_tx, status_rx, window_id: Mutex::new(None), - ipc_tx: Mutex::new(None), + is_render_initialized: AtomicBool::new(false), + is_running, + event_thread: std::sync::Mutex::new(Some(handle)), }) } @@ -84,8 +376,54 @@ impl PlayerController { pub async fn set_window_id(&self, wid: Option) { let mut guard = self.window_id.lock().await; *guard = wid; - if let Some(w) = wid { - tracing::info!("Registered window handle for MPV embedding: {}", w); + } + + pub fn render_context(&self) -> Option { + *self.render_ctx.lock().unwrap() + } + + pub fn ensure_render_context(&self, ctx: &eframe::egui::Context) { + if self.is_render_initialized.swap(true, Ordering::SeqCst) { + return; + } + + if self.mpv.0.is_null() { + return; + } + + unsafe { + let mut gl_init = libmpv_sys::mpv_opengl_init_params { + get_proc_address: Some(get_proc_address_cb), + get_proc_address_ctx: std::ptr::null_mut(), + extra_exts: std::ptr::null(), + }; + + let mut params = [ + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE, + data: libmpv_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut _, + }, + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, + data: &mut gl_init as *mut _ as *mut _, + }, + libmpv_sys::mpv_render_param { + type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_INVALID, + data: std::ptr::null_mut(), + }, + ]; + + let mut rc = std::ptr::null_mut(); + let res = libmpv_sys::mpv_render_context_create(&mut rc, self.mpv.0, params.as_mut_ptr()); + if res == 0 { + tracing::info!("libmpv OpenGL render context created successfully!"); + let ctx_ptr = Box::into_raw(Box::new(ctx.clone())) as *mut std::ffi::c_void; + libmpv_sys::mpv_render_context_set_update_callback(rc, Some(on_mpv_render_update), ctx_ptr); + *self.render_ctx.lock().unwrap() = Some(MpvRenderContext(rc)); + } else { + tracing::warn!("Failed to initialize libmpv OpenGL render context (code: {})", res); + self.is_render_initialized.store(false, Ordering::SeqCst); + } } } @@ -122,52 +460,6 @@ impl PlayerController { } self.notify_status().await; - // Clean up socket file if exists - let sock_path = std::path::Path::new(&self.config.ipc_socket); - if sock_path.exists() { - let _ = std::fs::remove_file(sock_path); - } - - // Kill any existing instance - { - let mut proc_guard = self.child_process.lock().await; - if let Some(mut child) = proc_guard.take() { - let _ = child.kill(); - } - } - - // Build MPV command - let mut cmd = Command::new(&self.config.mpv_path); - cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket)); - cmd.arg(format!("--hwdec={}", self.config.hwdec)); - cmd.arg(format!("--vo={}", self.config.vo)); - cmd.arg("--osd-bar=yes"); - cmd.arg("--osd-level=1"); - cmd.arg("--osd-font-size=28"); - cmd.arg("--osd-on-seek=msg-bar"); - cmd.arg("--input-vo-keyboard=no"); - cmd.arg("--input-cursor=no"); - cmd.arg("--input-cursor-passthrough=yes"); - - // Configure yt-dlp for YouTube streams - let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() { - "/home/bart/.local/bin/yt-dlp" - } else { - "yt-dlp" - }; - cmd.arg(format!("--script-opts=ytdl_hook-ytdl_path={}", ytdlp_path)); - cmd.arg("--ytdl-format=bestvideo[height<=?1080]+bestaudio/best"); - cmd.arg("--ytdl-raw-options=no-playlist="); - - let wid = *self.window_id.lock().await; - if let Some(w) = wid { - tracing::info!("Embedding MPV directly into window ID: {}", w); - cmd.arg(format!("--wid={}", w)); - } else if self.config.fullscreen { - cmd.arg("--fs"); - } - cmd.arg(format!("--title={}", title)); - // Strip playlist/index params from single YouTube video playback let play_url = if url.contains("youtube.com") || url.contains("youtu.be") { let mut u = url.to_string(); @@ -181,71 +473,20 @@ impl PlayerController { } else { url.to_string() }; - cmd.arg(play_url); - - match cmd.spawn() { - Ok(child) => { - let mut proc_guard = self.child_process.lock().await; - *proc_guard = Some(child); - } - Err(e) => { - let msg = format!("MPV niet gevonden ('{}')! Installeer MPV via: sudo apt install mpv", self.config.mpv_path); - tracing::warn!("{}: {}", msg, e); - let mut st = self.status.lock().await; - st.state = PlaybackState::Stopped; - st.error_message = Some(msg); - return Err(format!("Could not launch MPV: {}", e).into()); - } - } - - // Create persistent IPC command channel - let (ipc_tx, ipc_rx) = tokio::sync::mpsc::unbounded_channel::(); - { - let mut guard = self.ipc_tx.lock().await; - *guard = Some(ipc_tx); - } - - // Start background IPC monitoring task - let sock_file = self.config.ipc_socket.clone(); - let status_arc = self.status.clone(); - let status_tx = self.status_tx.clone(); - tokio::spawn(async move { - let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx, ipc_rx).await; - }); + // Always reset video and audio track selection to auto so subsequent content plays normally + self.mpv.cmd(&["set", "vid", "auto"]); + self.mpv.cmd(&["set", "aid", "auto"]); + self.mpv.cmd(&["loadfile", &play_url, "replace"]); Ok(()) } pub async fn play_pause(&self) { - let current_state = { - let st = self.status.lock().await; - st.state - }; - - let new_state = match current_state { - PlaybackState::Playing => PlaybackState::Paused, - PlaybackState::Paused => PlaybackState::Playing, - PlaybackState::Stopped => PlaybackState::Stopped, - }; - - let _ = self.send_ipc_cmd(&["osd-msg", "cycle", "pause"]).await; - { - let mut st = self.status.lock().await; - st.state = new_state; - } - self.notify_status().await; + self.mpv.cmd(&["cycle", "pause"]); } pub async fn stop(&self) { - let _ = self.send_ipc_cmd(&["stop"]).await; - { - let mut guard = self.ipc_tx.lock().await; - *guard = None; - } - let mut proc_guard = self.child_process.lock().await; - if let Some(mut child) = proc_guard.take() { - let _ = child.kill(); - } + self.mpv.cmd(&["stop"]); { let mut st = self.status.lock().await; st.state = PlaybackState::Stopped; @@ -261,28 +502,26 @@ impl PlayerController { } pub async fn seek(&self, seconds: f64) { - let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &seconds.to_string(), "relative"]).await; + self.mpv.cmd(&["seek", &seconds.to_string(), "relative"]); } pub async fn seek_percent(&self, percent: f64) { let p = percent.clamp(0.0, 100.0); - let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &p.to_string(), "absolute-percent"]).await; + self.mpv.cmd(&["seek", &p.to_string(), "absolute-percent"]); } pub async fn seek_time(&self, seconds: f64) { let s = seconds.max(0.0); - let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &s.to_string(), "absolute"]).await; + self.mpv.cmd(&["seek", &s.to_string(), "absolute"]); } pub async fn toggle_mute(&self) { - let new_muted = { + { let mut st = self.status.lock().await; st.is_muted = !st.is_muted; - st.is_muted - }; - let _ = self.send_ipc_cmd(&["cycle", "mute"]).await; + } + self.mpv.cmd(&["cycle", "mute"]); self.notify_status().await; - tracing::info!("Player mute toggled to {}", new_muted); } pub async fn set_mute(&self, muted: bool) { @@ -290,10 +529,8 @@ impl PlayerController { let mut st = self.status.lock().await; st.is_muted = muted; } - let arg = if muted { "yes" } else { "no" }; - let _ = self.send_ipc_cmd(&["set", "mute", arg]).await; + self.mpv.cmd(&["set", "mute", if muted { "yes" } else { "no" }]); self.notify_status().await; - tracing::info!("Player mute set to {}", muted); } pub async fn set_volume(&self, volume: f64) { @@ -302,266 +539,224 @@ impl PlayerController { let mut st = self.status.lock().await; st.volume = vol; } - let _ = self.send_ipc_cmd(&["osd-msg-bar", "set", "volume", &vol.to_string()]).await; + self.mpv.cmd(&["set", "volume", &vol.to_string()]); self.notify_status().await; - tracing::info!("Player volume set to {}", vol); } pub async fn volume_up(&self) { - let new_vol = { + { let mut st = self.status.lock().await; st.volume = (st.volume + 5.0).min(100.0); - st.volume - }; - let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "5"]).await; + } + self.mpv.cmd(&["add", "volume", "5"]); self.notify_status().await; - tracing::info!("Player volume increased to {}", new_vol); } pub async fn volume_down(&self) { - let new_vol = { + { let mut st = self.status.lock().await; st.volume = (st.volume - 5.0).max(0.0); - st.volume - }; - let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "-5"]).await; + } + self.mpv.cmd(&["add", "volume", "-5"]); self.notify_status().await; - tracing::info!("Player volume decreased to {}", new_vol); } pub async fn cycle_subtitle(&self) { - let _ = self.send_ipc_cmd(&["cycle", "sub"]).await; - let _ = self.send_ipc_cmd(&[ - "expand-properties", - "show-text", - "💬 Ondertitels: ${current-tracks/sub/title:${current-tracks/sub/lang:${sub:Geen beschikbaar}}}", - "2500", - ]).await; + self.mpv.cmd(&["cycle", "sub"]); tracing::info!("Cycled subtitle track"); } pub async fn cycle_audio(&self) { - let _ = self.send_ipc_cmd(&["cycle", "audio"]).await; - let _ = self.send_ipc_cmd(&[ - "expand-properties", - "show-text", - "🎬 Audio: ${current-tracks/audio/title:${current-tracks/audio/lang:${audio:Standaard}}}", - "2500", - ]).await; + self.mpv.cmd(&["cycle", "audio"]); tracing::info!("Cycled audio track"); } - pub async fn set_video_visible(&self, visible: bool) { - let arg = if visible { "auto" } else { "no" }; - let _ = self.send_ipc_cmd(&["set", "vid", arg]).await; - tracing::info!("Set MPV video visibility to {}", visible); + pub async fn set_audio_track(&self, track_id: i32) { + self.mpv.cmd(&["set", "aid", &track_id.to_string()]); + tracing::info!("Set audio track to aid={}", track_id); } - async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box> { - let guard = self.ipc_tx.lock().await; - if let Some(ref tx) = *guard { - let cmd_json = serde_json::json!({ - "command": args - }); - let _ = tx.send(cmd_json); - return Ok(()); - } - Err("MPV IPC not connected".into()) + pub async fn set_video_visible(&self, _visible: bool) { + // No-op with in-process libmpv OpenGL rendering; egui handles visibility directly. } } -async fn monitor_mpv_ipc( - sock_path: String, - status: Arc>, - status_tx: tokio::sync::watch::Sender, - mut ipc_rx: tokio::sync::mpsc::UnboundedReceiver, -) -> Result<(), Box> { - // Wait for socket to become available - for _ in 0..30 { - if std::path::Path::new(&sock_path).exists() { +impl Drop for PlayerController { + fn drop(&mut self) { + self.is_running.store(false, Ordering::SeqCst); + if !self.mpv.0.is_null() { + unsafe { + libmpv_sys::mpv_wakeup(self.mpv.0); + } + } + if let Ok(mut guard) = self.event_thread.lock() { + if let Some(handle) = guard.take() { + let _ = handle.join(); + } + } + let rc_opt = self.render_ctx.lock().unwrap().take(); + if let Some(rc) = rc_opt { + unsafe { + libmpv_sys::mpv_render_context_free(rc.0); + } + } + if !self.mpv.0.is_null() { + unsafe { + libmpv_sys::mpv_destroy(self.mpv.0); + } + } + } +} + +fn run_mpv_event_loop( + mpv: MpvHandle, + is_running: Arc, + event_tx: tokio::sync::mpsc::UnboundedSender, +) { + if mpv.0.is_null() { + return; + } + + while is_running.load(Ordering::SeqCst) { + let ev = unsafe { libmpv_sys::mpv_wait_event(mpv.0, 0.05) }; + if !is_running.load(Ordering::SeqCst) { break; } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } + if ev.is_null() { + continue; + } - let stream = match UnixStream::connect(&sock_path).await { - Ok(s) => s, - Err(_) => return Ok(()), - }; + let event_id = unsafe { (*ev).event_id }; + if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_SHUTDOWN { + break; + } - let (reader, mut writer) = stream.into_split(); - let mut lines = BufReader::new(reader).lines(); + if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_START_FILE { + let _ = event_tx.send(InternalMpvEvent::StartFile); + continue; + } - // Observe properties - let observe_cmds = [ - r#"{"command":["observe_property",1,"time-pos"]}"#, - r#"{"command":["observe_property",2,"duration"]}"#, - r#"{"command":["observe_property",3,"pause"]}"#, - r#"{"command":["observe_property",4,"volume"]}"#, - r#"{"command":["observe_property",5,"media-title"]}"#, - r#"{"command":["observe_property",6,"icy-title"]}"#, - r#"{"command":["observe_property",7,"metadata"]}"#, - r#"{"command":["observe_property",8,"mute"]}"#, - ]; + if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED { + let _ = event_tx.send(InternalMpvEvent::FileLoaded); + continue; + } - for c in &observe_cmds { - let _ = writer.write_all(format!("{}\n", c).as_bytes()).await; - } + if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_END_FILE { + let end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file }; + let (reason, error) = if !end_data.is_null() { + unsafe { ((*end_data).reason, (*end_data).error) } + } else { + (libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32, 0) + }; + let _ = event_tx.send(InternalMpvEvent::EndFile { reason, error }); + continue; + } - loop { - tokio::select! { - cmd_opt = ipc_rx.recv() => { - match cmd_opt { - Some(cmd_val) => { - let payload = format!("{}\n", cmd_val); - if let Err(e) = writer.write_all(payload.as_bytes()).await { - tracing::warn!("MPV IPC write error: {}", e); - break; + if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE { + let prop = unsafe { (*ev).data as *const libmpv_sys::mpv_event_property }; + if prop.is_null() { + continue; + } + + let prop_name = unsafe { + if (*prop).name.is_null() { + "" + } else { + CStr::from_ptr((*prop).name).to_str().unwrap_or("") + } + }; + + match prop_name { + "time-pos" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } { + let pos = unsafe { *((*prop).data as *const f64) }; + let _ = event_tx.send(InternalMpvEvent::TimePos(pos)); + } + } + "duration" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } { + let dur = unsafe { *((*prop).data as *const f64) }; + let _ = event_tx.send(InternalMpvEvent::Duration(dur)); + } + } + "pause" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } { + let paused = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 }; + let _ = event_tx.send(InternalMpvEvent::Pause(paused)); + } + } + "volume" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } { + let vol = unsafe { *((*prop).data as *const f64) }; + let _ = event_tx.send(InternalMpvEvent::Volume(vol)); + } + } + "mute" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } { + let m = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 }; + let _ = event_tx.send(InternalMpvEvent::Mute(m)); + } + } + "media-title" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } { + let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) }; + if !str_ptr.is_null() { + let mt = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim(); + if !mt.is_empty() { + let _ = event_tx.send(InternalMpvEvent::MediaTitle(mt.to_string())); + } } } - None => break, } - } - line_res = lines.next_line() => { - match line_res { - Ok(Some(line)) => { - if let Ok(v) = serde_json::from_str::(&line) { - if let Some(event) = v.get("event").and_then(|e| e.as_str()) { - if event == "property-change" { - let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(""); - let data = v.get("data"); - - let mut st = status.lock().await; - let mut notify = false; - match name { - "mute" => { - if let Some(m) = data.and_then(|d| d.as_bool()) { - if st.is_muted != m { - st.is_muted = m; - notify = true; - } + "icy-title" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } { + let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) }; + if !str_ptr.is_null() { + let icy = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim(); + if !icy.is_empty() { + let _ = event_tx.send(InternalMpvEvent::IcyTitle(icy.to_string())); } } - "time-pos" => { - if let Some(pos) = data.and_then(|d| d.as_f64()) { - st.time_pos = pos; - } - } - "duration" => { - if let Some(dur) = data.and_then(|d| d.as_f64()) { - st.duration = dur; - } - } - "pause" => { - if let Some(paused) = data.and_then(|d| d.as_bool()) { - let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing }; - if st.state != new_state { - st.state = new_state; - notify = true; - } - } - } - "volume" => { - if let Some(vol) = data.and_then(|d| d.as_f64()) { - if (st.volume - vol).abs() > 0.01 { - st.volume = vol; - notify = true; - } - } - } - "icy-title" => { - if let Some(icy) = data.and_then(|d| d.as_str()) { - let trimmed = icy.trim(); - if !trimmed.is_empty() && st.stream_title != trimmed { - tracing::info!("MPV ICY Stream title detected: {}", trimmed); - st.stream_title = trimmed.to_string(); - if st.media_type.is_empty() || st.media_type != "stream" { - st.media_type = "stream".to_string(); - } - notify = true; - } - } - } - "media-title" => { - if let Some(mt) = data.and_then(|d| d.as_str()) { - let trimmed = mt.trim(); - if !trimmed.is_empty() { - if st.media_type == "stream" { - if st.stream_title.is_empty() || st.stream_title != trimmed { - tracing::info!("MPV Stream media-title: {}", trimmed); - st.stream_title = trimmed.to_string(); - notify = true; - } - } else if st.title.is_empty() { - st.title = trimmed.to_string(); - notify = true; - } - } - } - } - "metadata" => { - if let Some(meta_obj) = data.and_then(|d| d.as_object()) { - let stream_t = meta_obj.get("icy-title") - .or_else(|| meta_obj.get("icy_title")) - .or_else(|| meta_obj.get("StreamTitle")) - .or_else(|| meta_obj.get("title")) - .or_else(|| meta_obj.get("Title")) - .and_then(|val| val.as_str()); - - if let Some(s) = stream_t { - let trimmed = s.trim(); - if !trimmed.is_empty() && (st.media_type == "stream" || meta_obj.contains_key("icy-title")) { - if st.stream_title != trimmed { - st.stream_title = trimmed.to_string(); - st.media_type = "stream".to_string(); - notify = true; + } + } + "metadata" => { + if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_NODE } && !unsafe { (*prop).data.is_null() } { + let node = unsafe { &*((*prop).data as *const libmpv_sys::mpv_node) }; + if node.format == libmpv_sys::mpv_format_MPV_FORMAT_NODE_MAP { + let mut artist = None; + let mut genre = None; + let mut stream_title = None; + unsafe { + let list = &*node.u.list; + for i in 0..list.num { + let key_ptr = *list.keys.offset(i as isize); + let val_node = list.values.offset(i as isize); + if !key_ptr.is_null() && !val_node.is_null() && (*val_node).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING { + let key = CStr::from_ptr(key_ptr).to_str().unwrap_or(""); + let val_ptr = (*val_node).u.string; + if !val_ptr.is_null() { + let val = CStr::from_ptr(val_ptr).to_str().unwrap_or("").trim(); + if !val.is_empty() { + if key.eq_ignore_ascii_case("artist") { + artist = Some(val.to_string()); + } else if key.eq_ignore_ascii_case("genre") { + genre = Some(val.to_string()); + } else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") { + stream_title = Some(val.to_string()); + } + } } } } - - if let Some(a) = meta_obj.get("artist").or_else(|| meta_obj.get("Artist")).and_then(|val| val.as_str()) { - let trimmed = a.trim(); - if !trimmed.is_empty() && st.artist != trimmed { - st.artist = trimmed.to_string(); - notify = true; - } - } - - if let Some(g) = meta_obj.get("genre").or_else(|| meta_obj.get("Genre")).or_else(|| meta_obj.get("icy-genre")).and_then(|val| val.as_str()) { - let trimmed = g.trim(); - if !trimmed.is_empty() && st.genre != trimmed { - st.genre = trimmed.to_string(); - notify = true; - } - } + } + if artist.is_some() || genre.is_some() || stream_title.is_some() { + let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title }); } } - _ => {} } - if notify { - let _ = status_tx.send(st.clone()); - } - } else if event == "end-file" { - let mut st = status.lock().await; - st.state = PlaybackState::Stopped; - st.media_type.clear(); - st.title.clear(); - st.artist.clear(); - st.genre.clear(); - st.stream_title.clear(); - st.time_pos = 0.0; - st.duration = 0.0; - let _ = status_tx.send(st.clone()); - break; - } - } - } - } - _ => break, } + _ => {} } } } - - Ok(()) } diff --git a/jellyfin-kiosk-rs/src/x11_overlay.rs b/jellyfin-kiosk-rs/src/x11_overlay.rs deleted file mode 100644 index ec44ba3..0000000 --- a/jellyfin-kiosk-rs/src/x11_overlay.rs +++ /dev/null @@ -1,316 +0,0 @@ -use std::ffi::{c_char, c_int, c_uint, c_void}; -use std::sync::Mutex; - -extern "C" { - fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void; - fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; - fn dlclose(handle: *mut c_void) -> c_int; -} - -const RTLD_LAZY: c_int = 1; - -#[repr(C)] -struct XPoint { - x: i16, - y: i16, -} - -type FnXOpenDisplay = unsafe extern "C" fn(*const c_char) -> *mut c_void; -type FnXCloseDisplay = unsafe extern "C" fn(*mut c_void) -> c_int; -type FnXQueryTree = unsafe extern "C" fn( - *mut c_void, - u64, - *mut u64, - *mut u64, - *mut *mut u64, - *mut c_uint, -) -> c_int; -type FnXFree = unsafe extern "C" fn(*mut c_void) -> c_int; -type FnXMoveResizeWindow = - unsafe extern "C" fn(*mut c_void, u64, c_int, c_int, c_uint, c_uint) -> c_int; -type FnXMapWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; -type FnXUnmapWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; -type FnXFlush = unsafe extern "C" fn(*mut c_void) -> c_int; - -type FnXCreateSimpleWindow = unsafe extern "C" fn( - *mut c_void, - u64, - c_int, - c_int, - c_uint, - c_uint, - c_uint, - u64, - u64, -) -> u64; -type FnXDestroyWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; -type FnXRaiseWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; -type FnXCreateGC = unsafe extern "C" fn(*mut c_void, u64, u64, *mut c_void) -> *mut c_void; -type FnXFreeGC = unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int; -type FnXSetForeground = unsafe extern "C" fn(*mut c_void, *mut c_void, u64) -> c_int; -type FnXSetLineAttributes = unsafe extern "C" fn(*mut c_void, *mut c_void, c_uint, c_int, c_int, c_int) -> c_int; -type FnXFillRectangle = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, c_int, c_int, c_uint, c_uint) -> c_int; -type FnXFillPolygon = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, *const XPoint, c_int, c_int, c_int) -> c_int; -type FnXDrawLine = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, c_int, c_int, c_int, c_int) -> c_int; - -pub struct X11Overlay { - _handle: *mut c_void, - xopen_display: FnXOpenDisplay, - xclose_display: FnXCloseDisplay, - xquery_tree: FnXQueryTree, - xfree: FnXFree, - xmove_resize_window: FnXMoveResizeWindow, - xmap_window: FnXMapWindow, - xunmap_window: FnXUnmapWindow, - xflush: FnXFlush, - - xcreate_simple_window: FnXCreateSimpleWindow, - xdestroy_window: FnXDestroyWindow, - xraise_window: FnXRaiseWindow, - xcreate_gc: FnXCreateGC, - xfree_gc: FnXFreeGC, - xset_foreground: FnXSetForeground, - xset_line_attributes: FnXSetLineAttributes, - xfill_rectangle: FnXFillRectangle, - xfill_polygon: FnXFillPolygon, - xdraw_line: FnXDrawLine, - - last_geom: Mutex>, - mute_window: Mutex>, - last_mute_state: Mutex>, -} - -// Implement Send and Sync because all calls open and close their own Display connection -unsafe impl Send for X11Overlay {} -unsafe impl Sync for X11Overlay {} - -impl X11Overlay { - pub fn try_new() -> Option { - unsafe { - let handle = dlopen(b"libX11.so.6\0".as_ptr() as *const _, RTLD_LAZY); - if handle.is_null() { - return None; - } - - macro_rules! sym { - ($name:expr, $ty:ty) => {{ - let s = dlsym(handle, $name.as_ptr() as *const _); - if s.is_null() { - dlclose(handle); - return None; - } - std::mem::transmute::<*mut c_void, $ty>(s) - }}; - } - - let xopen_display = sym!(b"XOpenDisplay\0", FnXOpenDisplay); - let xclose_display = sym!(b"XCloseDisplay\0", FnXCloseDisplay); - let xquery_tree = sym!(b"XQueryTree\0", FnXQueryTree); - let xfree = sym!(b"XFree\0", FnXFree); - let xmove_resize_window = sym!(b"XMoveResizeWindow\0", FnXMoveResizeWindow); - let xmap_window = sym!(b"XMapWindow\0", FnXMapWindow); - let xunmap_window = sym!(b"XUnmapWindow\0", FnXUnmapWindow); - let xflush = sym!(b"XFlush\0", FnXFlush); - - let xcreate_simple_window = sym!(b"XCreateSimpleWindow\0", FnXCreateSimpleWindow); - let xdestroy_window = sym!(b"XDestroyWindow\0", FnXDestroyWindow); - let xraise_window = sym!(b"XRaiseWindow\0", FnXRaiseWindow); - let xcreate_gc = sym!(b"XCreateGC\0", FnXCreateGC); - let xfree_gc = sym!(b"XFreeGC\0", FnXFreeGC); - let xset_foreground = sym!(b"XSetForeground\0", FnXSetForeground); - let xset_line_attributes = sym!(b"XSetLineAttributes\0", FnXSetLineAttributes); - let xfill_rectangle = sym!(b"XFillRectangle\0", FnXFillRectangle); - let xfill_polygon = sym!(b"XFillPolygon\0", FnXFillPolygon); - let xdraw_line = sym!(b"XDrawLine\0", FnXDrawLine); - - Some(Self { - _handle: handle, - xopen_display, - xclose_display, - xquery_tree, - xfree, - xmove_resize_window, - xmap_window, - xunmap_window, - xflush, - xcreate_simple_window, - xdestroy_window, - xraise_window, - xcreate_gc, - xfree_gc, - xset_foreground, - xset_line_attributes, - xfill_rectangle, - xfill_polygon, - xdraw_line, - last_geom: Mutex::new(None), - mute_window: Mutex::new(None), - last_mute_state: Mutex::new(None), - }) - } - } - - pub fn reset_cache(&self) { - if let Ok(mut g) = self.last_geom.lock() { - *g = None; - } - if let Ok(mut m) = self.last_mute_state.lock() { - *m = None; - } - } - - pub fn set_geometry( - &self, - parent_wid: u64, - x: i32, - y: i32, - width: u32, - height: u32, - visible: bool, - ) { - let target = (x, y, width, height, visible); - if let Ok(mut g) = self.last_geom.lock() { - if *g == Some(target) { - return; - } - *g = Some(target); - } - - unsafe { - let display = (self.xopen_display)(std::ptr::null()); - if display.is_null() { - return; - } - - let mut root = 0u64; - let mut parent = 0u64; - let mut children: *mut u64 = std::ptr::null_mut(); - let mut nchildren: c_uint = 0; - - let status = (self.xquery_tree)( - display, - parent_wid, - &mut root, - &mut parent, - &mut children, - &mut nchildren, - ); - - let mute_win = self.mute_window.lock().ok().and_then(|g| *g); - - if status != 0 && !children.is_null() && nchildren > 0 { - for i in 0..(nchildren as usize) { - let child = *children.add(i); - // Don't modify the mute badge window here - if Some(child) == mute_win { - continue; - } - if visible { - (self.xmap_window)(display, child); - (self.xmove_resize_window)(display, child, x, y, width, height); - } else { - (self.xunmap_window)(display, child); - } - } - (self.xfree)(children as *mut c_void); - (self.xflush)(display); - } - - (self.xclose_display)(display); - } - } - - pub fn sync_mute_badge(&self, parent_wid: u64, screen_w: u32, is_muted: bool) { - if let Ok(mut st) = self.last_mute_state.lock() { - if *st == Some((is_muted, screen_w)) { - return; - } - *st = Some((is_muted, screen_w)); - } - - unsafe { - let display = (self.xopen_display)(std::ptr::null()); - if display.is_null() { - return; - } - - let mut mute_win = { - let g = self.mute_window.lock().unwrap(); - *g - }; - - if is_muted { - let x = (screen_w as i32) - 58; - let y = 16i32; - let w = 36u32; - let h = 24u32; - - if mute_win.is_none() { - let win = (self.xcreate_simple_window)( - display, - parent_wid, - x, - y, - w, - h, - 1, - 0xFF5A5A, - 0xD71E1E, - ); - let mut g = self.mute_window.lock().unwrap(); - *g = Some(win); - mute_win = Some(win); - } - - if let Some(win) = mute_win { - (self.xmove_resize_window)(display, win, x, y, w, h); - (self.xmap_window)(display, win); - (self.xraise_window)(display, win); - - // Draw speaker icon & mute slash - let gc = (self.xcreate_gc)(display, win, 0, std::ptr::null_mut()); - if !gc.is_null() { - (self.xset_foreground)(display, gc, 0xFFFFFF); - // Speaker body - (self.xfill_rectangle)(display, win, gc, 7, 8, 5, 8); - // Speaker cone - let points = [ - XPoint { x: 12, y: 8 }, - XPoint { x: 18, y: 4 }, - XPoint { x: 18, y: 20 }, - XPoint { x: 12, y: 16 }, - ]; - (self.xfill_polygon)(display, win, gc, points.as_ptr(), 4, 1, 0); - // Mute slash - (self.xset_line_attributes)(display, gc, 2, 0, 0, 0); - (self.xdraw_line)(display, win, gc, 22, 7, 28, 17); - (self.xdraw_line)(display, win, gc, 22, 17, 28, 7); - (self.xfree_gc)(display, gc); - } - } - } else if let Some(win) = mute_win { - (self.xunmap_window)(display, win); - } - - (self.xflush)(display); - (self.xclose_display)(display); - } - } -} - -impl Drop for X11Overlay { - fn drop(&mut self) { - if let Ok(mut g) = self.mute_window.lock() { - if let Some(win) = g.take() { - unsafe { - let display = (self.xopen_display)(std::ptr::null()); - if !display.is_null() { - (self.xdestroy_window)(display, win); - (self.xflush)(display); - (self.xclose_display)(display); - } - } - } - } - } -} diff --git a/jellyfin-kiosk-rs/src/yatse.rs b/jellyfin-kiosk-rs/src/yatse.rs index d712e08..2201d2e 100644 --- a/jellyfin-kiosk-rs/src/yatse.rs +++ b/jellyfin-kiosk-rs/src/yatse.rs @@ -88,7 +88,89 @@ async fn handle_root() -> impl IntoResponse { "Jellyfin HTPC Kiosk (Rust) - Kodi JSON-RPC Server for Yatse" } -async fn handle_jsonrpc_get() -> impl IntoResponse { +fn percent_decode(input: &str) -> String { + let mut bytes = Vec::new(); + let mut chars = input.bytes(); + while let Some(b) = chars.next() { + if b == b'%' { + let h1 = chars.next().unwrap_or(b'0'); + let h2 = chars.next().unwrap_or(b'0'); + let hex = [h1, h2]; + if let Ok(s) = std::str::from_utf8(&hex) { + if let Ok(val) = u8::from_str_radix(s, 16) { + bytes.push(val); + continue; + } + } + bytes.push(b'%'); + bytes.push(h1); + bytes.push(h2); + } else if b == b'+' { + bytes.push(b' '); + } else { + bytes.push(b); + } + } + String::from_utf8_lossy(&bytes).to_string() +} + +pub fn normalize_playback_url(file: &str) -> String { + let trimmed = file.trim(); + if trimmed.starts_with("plugin://plugin.video.youtube") { + if let Some(query) = trimmed.split('?').nth(1) { + for param in query.split('&') { + let mut kv = param.splitn(2, '='); + if let (Some(k), Some(v)) = (kv.next(), kv.next()) { + let k_lower = k.to_lowercase(); + if k_lower == "video_id" || k_lower == "videoid" { + let vid = percent_decode(v); + return format!("https://www.youtube.com/watch?v={}", vid); + } + } + } + } + } else if trimmed.starts_with("plugin://plugin.video.sendtokodi") { + if let Some(query) = trimmed.split('?').nth(1) { + let decoded = percent_decode(query); + if decoded.starts_with("http://") || decoded.starts_with("https://") { + return decoded; + } + if let Some(url_part) = decoded.split("url=").nth(1) { + return percent_decode(url_part); + } + } + } + trimmed.to_string() +} + +async fn handle_jsonrpc_get( + State(state): State, + uri: axum::http::Uri, +) -> impl IntoResponse { + if let Some(query) = uri.query() { + for pair in query.split('&') { + let mut parts = pair.splitn(2, '='); + if let (Some(k), Some(v)) = (parts.next(), parts.next()) { + if k == "request" { + let decoded = percent_decode(v); + if let Ok(val) = serde_json::from_str::(&decoded) { + if let Some(arr) = val.as_array() { + let mut results = Vec::new(); + for item in arr { + if let Ok(parsed) = serde_json::from_value::(item.clone()) { + results.push(dispatch_rpc(&state, parsed).await); + } + } + return Json(Value::Array(results)); + } else if let Ok(parsed) = serde_json::from_value::(val) { + let res = dispatch_rpc(&state, parsed).await; + return Json(res); + } + } + } + } + } + } Json(json!({ "jsonrpc": "2.0", "result": { @@ -98,6 +180,76 @@ async fn handle_jsonrpc_get() -> impl IntoResponse { })) } +pub async fn start_tcp_jsonrpc_server(port: u16, state: YatseServerState) { + let addr = format!("0.0.0.0:{}", port); + let listener = match tokio::net::TcpListener::bind(&addr).await { + Ok(l) => { + tracing::info!("Yatse Kodi TCP JSON-RPC socket server listening on {}", addr); + l + } + Err(e) => { + tracing::warn!("Failed to bind TCP JSON-RPC server on {}: {}", addr, e); + return; + } + }; + + tokio::spawn(async move { + loop { + let (socket, peer) = match listener.accept().await { + Ok(res) => res, + Err(e) => { + tracing::warn!("Error accepting TCP JSON-RPC connection: {}", e); + continue; + } + }; + + tracing::info!("Yatse TCP JSON-RPC connection accepted from {}", peer); + let state_clone = state.clone(); + + tokio::spawn(async move { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let (reader, mut writer) = socket.into_split(); + let mut buf_reader = BufReader::new(reader); + let mut line = String::new(); + + loop { + line.clear(); + match buf_reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(val) = serde_json::from_str::(trimmed) { + if let Some(arr) = val.as_array() { + let mut results = Vec::new(); + for item in arr { + if let Ok(parsed) = serde_json::from_value::(item.clone()) { + results.push(dispatch_rpc(&state_clone, parsed).await); + } + } + let out = serde_json::to_string(&Value::Array(results)).unwrap_or_default() + "\n"; + let _ = writer.write_all(out.as_bytes()).await; + } else if let Ok(parsed) = serde_json::from_value::(val) { + let res = dispatch_rpc(&state_clone, parsed).await; + let out = serde_json::to_string(&res).unwrap_or_default() + "\n"; + let _ = writer.write_all(out.as_bytes()).await; + } + } + } + Err(e) => { + tracing::debug!("TCP client {} read error: {}", peer, e); + break; + } + } + } + tracing::info!("Yatse TCP connection closed from {}", peer); + }); + } + }); +} + async fn handle_jsonrpc( State(state): State, Json(req): Json, @@ -181,18 +333,15 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) { if v_str == "increment" { state.player.volume_up().await; - let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); state.player.status.lock().await.volume as i64 } else if v_str == "decrement" { state.player.volume_down().await; - let _ = state.nav_tx.send(RemoteNavAction::VolumeDown); state.player.status.lock().await.volume as i64 } else { state.player.status.lock().await.volume as i64 } } else if let Some(v) = vol_param.and_then(|v| v.as_f64()) { state.player.set_volume(v).await; - let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); v.clamp(0.0, 100.0) as i64 } else { state.player.status.lock().await.volume as i64 @@ -351,7 +500,22 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Player.SetAudioStream" => { - state.player.cycle_audio().await; + if let Some(ref params) = req.params { + let stream_param = params.get("stream").or_else(|| params.as_array().and_then(|a| a.get(1))); + if let Some(s) = stream_param { + if let Some(idx) = s.as_i64() { + state.player.set_audio_track((idx + 1) as i32).await; + } else if s.as_str() == Some("next") { + state.player.cycle_audio().await; + } else if s.as_str() == Some("previous") { + state.player.cycle_audio().await; + } + } else { + state.player.cycle_audio().await; + } + } else { + state.player.cycle_audio().await; + } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Player.SetSpeed" => { @@ -385,10 +549,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { if let Some(params) = req.params { if let Some(item) = params.get("item") { if let Some(file) = item.get("file").and_then(|f| f.as_str()) { - let file_str = file.to_string(); + let norm_url = normalize_playback_url(file); + tracing::info!("Player.Open requested file: '{}' -> normalized: '{}'", file, norm_url); let p = state.player.clone(); tokio::spawn(async move { - let _ = p.play(&file_str, "Media from Yatse").await; + let _ = p.play(&norm_url, "Media from Yatse").await; }); } } @@ -460,19 +625,16 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { } "title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); } "volumeup" | "88" => { - let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); let p = state.player.clone(); tokio::spawn(async move { p.volume_up().await; }); } "volumedown" | "89" => { - let _ = state.nav_tx.send(RemoteNavAction::VolumeDown); let p = state.player.clone(); tokio::spawn(async move { p.volume_down().await; }); } "playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; } "stop" | "14" => { let _ = state.nav_tx.send(RemoteNavAction::Stop); - state.player.stop().await; } "mute" | "togglemute" | "13" => { let p = state.player.clone(); @@ -648,6 +810,112 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { "GUI.ShowNotification" => { json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } + "Addons.GetAddonDetails" => { + let addon_id = req.params.as_ref() + .and_then(|p| p.get("addonid").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|s| s.as_str()) + .unwrap_or("plugin.video.youtube"); + + let (name, a_type, version) = match addon_id { + "plugin.video.youtube" => ("YouTube", "xbmc.python.pluginsource", "7.0.0"), + "script.yatse.kodi" => ("Yatse Kodi Helper Script", "xbmc.service", "2.0.0"), + "plugin.video.sendtokodi" | "plugin.video.sendtokodi.python3" => ("SendToKodi", "xbmc.python.pluginsource", "1.0.0"), + other => (other, "xbmc.python.pluginsource", "1.0.0"), + }; + + json!({ + "jsonrpc": "2.0", + "result": { + "addon": { + "addonid": addon_id, + "name": name, + "type": a_type, + "version": version, + "summary": name, + "description": name, + "author": "Yatse / Community", + "enabled": true + } + }, + "id": id + }) + } + "Addons.GetAddons" => { + json!({ + "jsonrpc": "2.0", + "result": { + "addons": [ + { + "addonid": "plugin.video.youtube", + "name": "YouTube", + "type": "xbmc.python.pluginsource", + "version": "7.0.0", + "enabled": true + }, + { + "addonid": "script.yatse.kodi", + "name": "Yatse Kodi Helper Script", + "type": "xbmc.service", + "version": "2.0.0", + "enabled": true + }, + { + "addonid": "plugin.video.sendtokodi", + "name": "SendToKodi", + "type": "xbmc.python.pluginsource", + "version": "1.0.0", + "enabled": true + } + ], + "limits": { + "start": 0, + "end": 3, + "total": 3 + } + }, + "id": id + }) + } + "Addons.ExecuteAddon" => { + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Files.GetSources" => { + let media = req.params.as_ref() + .and_then(|p| p.get("media").or_else(|| p.as_array().and_then(|a| a.first()))) + .and_then(|s| s.as_str()) + .unwrap_or("video"); + + let sources = if media == "video" { + vec![ + json!({ + "file": "plugin://plugin.video.youtube/", + "label": "YouTube", + "media": "video" + }) + ] + } else { + vec![] + }; + + json!({ + "jsonrpc": "2.0", + "result": { + "sources": sources, + "limits": {"start": 0, "end": 1, "total": 1} + }, + "id": id + }) + } + "Files.GetDirectory" => { + json!({ + "jsonrpc": "2.0", + "result": { + "files": [], + "limits": {"start": 0, "end": 0, "total": 0} + }, + "id": id + }) + } _ => { json!({ "jsonrpc": "2.0", @@ -671,3 +939,28 @@ fn seconds_to_kodi_time(seconds: f64) -> Value { "milliseconds": millis }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_playback_url() { + assert_eq!( + normalize_playback_url("plugin://plugin.video.youtube/play/?video_id=6G45Qk0HQNY"), + "https://www.youtube.com/watch?v=6G45Qk0HQNY" + ); + assert_eq!( + normalize_playback_url("plugin://plugin.video.youtube/?action=play_video&videoid=6G45Qk0HQNY"), + "https://www.youtube.com/watch?v=6G45Qk0HQNY" + ); + assert_eq!( + normalize_playback_url("plugin://plugin.video.sendtokodi/?https%3A%2F%2Fyoutube.com%2Fwatch%3Fv%3D6G45Qk0HQNY"), + "https://youtube.com/watch?v=6G45Qk0HQNY" + ); + assert_eq!( + normalize_playback_url("https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei"), + "https://youtube.com/watch?v=6G45Qk0HQNY&is=6-OPTODo-ca9bhei" + ); + } +} diff --git a/remote-build.sh b/remote-build.sh index 0a79767..ec12e99 100755 --- a/remote-build.sh +++ b/remote-build.sh @@ -53,7 +53,7 @@ if [ "${FETCH_BIN}" = true ]; then echo "==> 📥 Downloading compiled release binary to laptop..." mkdir -p "${KIOSK_DIR}/target/release" rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/target/release/" - cp "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk" + install -m 755 "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk" echo "==> ✅ Klaar! Binary beschikbaar in:" echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" echo " - ${KIOSK_DIR}/jellyfin-kiosk"