feat(kiosk): integrate libmpv, full Yatse JSON-RPC & EventServer, and YouTube playback
This commit is contained in:
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+533
-338
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user