feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback

This commit is contained in:
bart
2026-11-04 23:26:08 +01:00
parent d77cdfedab
commit 4f2cdbd817
12 changed files with 963 additions and 736 deletions
+8
View File
@@ -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"
+2
View File
@@ -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 }
+31
View File
@@ -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");
}
Binary file not shown.
+73 -49
View File
@@ -184,8 +184,8 @@ pub struct KioskApp {
event_rx: UnboundedReceiver<AppEvent>,
pub image_cache: crate::image_cache::AsyncImageCache,
egui_ctx: Option<egui::Context>,
window_id: Option<u64>,
x11_overlay: Option<crate::x11_overlay::X11Overlay>,
_window_id: Option<u64>,
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();
+7
View File
@@ -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(),
}
}
}
-20
View File
@@ -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; });
}
+5 -2
View File
@@ -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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
}
});
// 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;
+517 -322
View File
@@ -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<CString> = 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<String>,
genre: Option<String>,
stream_title: Option<String>,
},
EndFile {
reason: i32,
error: i32,
},
}
pub struct PlayerController {
config: PlayerConfig,
child_process: Mutex<Option<Child>>,
_config: PlayerConfig,
pub mpv: MpvHandle,
pub render_ctx: Arc<std::sync::Mutex<Option<MpvRenderContext>>>,
pub status: Arc<Mutex<PlayerStatus>>,
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
status_rx: tokio::sync::watch::Receiver<PlayerStatus>,
pub window_id: Mutex<Option<u64>>,
pub ipc_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<serde_json::Value>>>,
is_render_initialized: AtomicBool,
is_running: Arc<AtomicBool>,
event_thread: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
}
impl PlayerController {
pub fn new(config: PlayerConfig) -> Arc<Self> {
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::<InternalMpvEvent>();
// 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<u64>) {
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<MpvRenderContext> {
*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::<serde_json::Value>();
{
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<dyn std::error::Error + Send + Sync>> {
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<Mutex<PlayerStatus>>,
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
mut ipc_rx: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 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<AtomicBool>,
event_tx: tokio::sync::mpsc::UnboundedSender<InternalMpvEvent>,
) {
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;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_START_FILE {
let _ = event_tx.send(InternalMpvEvent::StartFile);
continue;
}
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED {
let _ = event_tx.send(InternalMpvEvent::FileLoaded);
continue;
}
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;
}
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("")
}
};
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
// 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"]}"#,
];
for c in &observe_cmds {
let _ = writer.write_all(format!("{}\n", c).as_bytes()).await;
}
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;
}
}
None => break,
}
}
line_res = lines.next_line() => {
match line_res {
Ok(Some(line)) => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&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;
}
}
}
match prop_name {
"time-pos" => {
if let Some(pos) = data.and_then(|d| d.as_f64()) {
st.time_pos = 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 let Some(dur) = data.and_then(|d| d.as_f64()) {
st.duration = dur;
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 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;
}
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 let Some(vol) = data.and_then(|d| d.as_f64()) {
if (st.volume - vol).abs() > 0.01 {
st.volume = vol;
notify = true;
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()));
}
}
}
}
"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;
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()));
}
}
}
}
"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;
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(())
}
-316
View File
@@ -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<Option<(i32, i32, u32, u32, bool)>>,
mute_window: Mutex<Option<u64>>,
last_mute_state: Mutex<Option<(bool, u32)>>,
}
// 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<Self> {
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);
}
}
}
}
}
}
+302 -9
View File
@@ -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<YatseServerState>,
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::<Value>(&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::<JsonRpcRequest>(item.clone()) {
results.push(dispatch_rpc(&state, parsed).await);
}
}
return Json(Value::Array(results));
} else if let Ok(parsed) = serde_json::from_value::<JsonRpcRequest>(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::<Value>(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::<JsonRpcRequest>(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::<JsonRpcRequest>(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<YatseServerState>,
Json(req): Json<Value>,
@@ -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" => {
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"
);
}
}
+1 -1
View File
@@ -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"