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;
File diff suppressed because it is too large Load Diff
-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);
}
}
}
}
}
}
+303 -10
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" => {
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"
);
}
}
+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"