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",
|
"eframe",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
|
"libmpv-sys",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rumqttc",
|
"rumqttc",
|
||||||
@@ -1595,6 +1597,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libmpv-sys"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0df938d3145cd8f134572721a27afa3a51f9bc1c26ae30a1d5077162f96d074b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.25"
|
version = "0.1.25"
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
rumqttc = { version = "0.25", default-features = false }
|
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>,
|
event_rx: UnboundedReceiver<AppEvent>,
|
||||||
pub image_cache: crate::image_cache::AsyncImageCache,
|
pub image_cache: crate::image_cache::AsyncImageCache,
|
||||||
egui_ctx: Option<egui::Context>,
|
egui_ctx: Option<egui::Context>,
|
||||||
window_id: Option<u64>,
|
_window_id: Option<u64>,
|
||||||
x11_overlay: Option<crate::x11_overlay::X11Overlay>,
|
last_playing_title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KioskApp {
|
impl KioskApp {
|
||||||
@@ -269,8 +269,8 @@ impl KioskApp {
|
|||||||
event_rx,
|
event_rx,
|
||||||
image_cache,
|
image_cache,
|
||||||
egui_ctx: None,
|
egui_ctx: None,
|
||||||
window_id,
|
_window_id: window_id,
|
||||||
x11_overlay: crate::x11_overlay::X11Overlay::try_new(),
|
last_playing_title: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +423,9 @@ impl KioskApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if item.item_type == "YouTubeVideo" || item.id.starts_with("http") {
|
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 player = self.player.clone();
|
||||||
let url = item.id.clone();
|
let url = item.id.clone();
|
||||||
let name = item.name.clone();
|
let name = item.name.clone();
|
||||||
@@ -1075,6 +1078,9 @@ impl KioskApp {
|
|||||||
if self.selected_grid_idx == 0 {
|
if self.selected_grid_idx == 0 {
|
||||||
// Play first track or all tracks
|
// Play first track or all tracks
|
||||||
if let Some(first_track) = self.folder_items.first() {
|
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 client = self.client.clone();
|
||||||
let player = self.player.clone();
|
let player = self.player.clone();
|
||||||
let item = first_track.clone();
|
let item = first_track.clone();
|
||||||
@@ -1087,6 +1093,9 @@ impl KioskApp {
|
|||||||
} else {
|
} else {
|
||||||
let track_idx = self.selected_grid_idx - 1;
|
let track_idx = self.selected_grid_idx - 1;
|
||||||
if let Some(track) = self.folder_items.get(track_idx) {
|
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 client = self.client.clone();
|
||||||
let player = self.player.clone();
|
let player = self.player.clone();
|
||||||
let item = track.clone();
|
let item = track.clone();
|
||||||
@@ -1189,9 +1198,14 @@ impl KioskApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl eframe::App for 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());
|
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
|
// Update async image cache textures
|
||||||
self.image_cache.update(ctx);
|
self.image_cache.update(ctx);
|
||||||
|
|
||||||
@@ -1314,7 +1328,7 @@ impl eframe::App for KioskApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check player status
|
// 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 st.error_message != self.player_error_msg {
|
||||||
if let Some(ref em) = st.error_message {
|
if let Some(ref em) = st.error_message {
|
||||||
self.active_error_dialog = Some(em.clone());
|
self.active_error_dialog = Some(em.clone());
|
||||||
@@ -1322,10 +1336,11 @@ impl eframe::App for KioskApp {
|
|||||||
self.player_error_msg = st.error_message.clone();
|
self.player_error_msg = st.error_message.clone();
|
||||||
}
|
}
|
||||||
let is_aud = st.media_type == "audio"
|
let is_aud = st.media_type == "audio"
|
||||||
|| (st.media_type != "video" && st.media_type != "stream" && st.duration > 0.0 && !st.artist.is_empty());
|
|| st.media_type == "music"
|
||||||
(st.state != PlaybackState::Stopped, is_aud, st.volume, st.is_muted)
|
|| (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 {
|
} 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 {
|
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));
|
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.view_mode = ViewMode::Player;
|
||||||
self.playback_osd_open = true;
|
self.playback_osd_open = true;
|
||||||
self.playback_osd_idx = 2; // Pause/Play
|
self.playback_osd_idx = 2; // Pause/Play
|
||||||
if let Some(ref overlay) = self.x11_overlay {
|
self.last_playing_title = current_title;
|
||||||
overlay.reset_cache();
|
|
||||||
}
|
|
||||||
} else if !is_playing && self.was_playing {
|
} else if !is_playing && self.was_playing {
|
||||||
self.view_mode = ViewMode::Browser;
|
self.view_mode = ViewMode::Browser;
|
||||||
self.playback_osd_open = false;
|
self.playback_osd_open = false;
|
||||||
|
self.last_playing_title.clear();
|
||||||
}
|
}
|
||||||
self.was_playing = is_playing;
|
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 {
|
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));
|
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)
|
("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
|
// Panel background: solid dark slate for music player; transparent for video so MPV renders directly into OpenGL
|
||||||
let panel_fill = if is_audio || self.playback_osd_open {
|
let panel_fill = if is_audio {
|
||||||
Color32::from_rgb(10, 14, 20)
|
Color32::from_rgb(10, 14, 20)
|
||||||
} else {
|
} else {
|
||||||
Color32::TRANSPARENT
|
Color32::TRANSPARENT
|
||||||
@@ -1591,14 +1579,47 @@ impl eframe::App for KioskApp {
|
|||||||
let screen_w = screen_rect.width();
|
let screen_w = screen_rect.width();
|
||||||
let screen_h = screen_rect.height();
|
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
|
// Mouse click on video area toggles playback OSD menu
|
||||||
if !is_audio {
|
if !is_audio {
|
||||||
let video_click_rect = if self.playback_osd_open {
|
let video_resp = ui.interact(screen_rect, ui.id().with("video_osd_toggle"), egui::Sense::click());
|
||||||
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());
|
|
||||||
if video_resp.clicked() {
|
if video_resp.clicked() {
|
||||||
self.playback_osd_open = !self.playback_osd_open;
|
self.playback_osd_open = !self.playback_osd_open;
|
||||||
if 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 {
|
if let Some(t_idx) = clicked_track_idx {
|
||||||
self.selected_grid_idx = t_idx + 1;
|
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) {
|
if let Some(track) = self.folder_items.get(t_idx) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let player = self.player.clone();
|
let player = self.player.clone();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ fn default_device_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct PlayerConfig {
|
pub struct PlayerConfig {
|
||||||
#[serde(default = "default_mpv_path")]
|
#[serde(default = "default_mpv_path")]
|
||||||
pub mpv_path: String,
|
pub mpv_path: String,
|
||||||
@@ -85,6 +86,8 @@ pub struct KodiRpcConfig {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
#[serde(default = "default_http_port")]
|
#[serde(default = "default_http_port")]
|
||||||
pub http_port: u16,
|
pub http_port: u16,
|
||||||
|
#[serde(default = "default_ws_port")]
|
||||||
|
pub ws_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -93,12 +96,16 @@ fn default_true() -> bool {
|
|||||||
fn default_http_port() -> u16 {
|
fn default_http_port() -> u16 {
|
||||||
8080
|
8080
|
||||||
}
|
}
|
||||||
|
fn default_ws_port() -> u16 {
|
||||||
|
9090
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for KodiRpcConfig {
|
impl Default for KodiRpcConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: default_true(),
|
enabled: default_true(),
|
||||||
http_port: default_http_port(),
|
http_port: default_http_port(),
|
||||||
|
ws_port: default_ws_port(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,31 +287,23 @@ pub fn dispatch_button_or_code(
|
|||||||
}
|
}
|
||||||
"audionextlanguage" | "nextaudio" | "cycleaudio" => {
|
"audionextlanguage" | "nextaudio" | "cycleaudio" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.cycle_audio().await; });
|
|
||||||
}
|
}
|
||||||
"play" | "pause" | "playpause" => {
|
"play" | "pause" | "playpause" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::PlayPause);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.play_pause().await; });
|
tokio::spawn(async move { p.play_pause().await; });
|
||||||
}
|
}
|
||||||
"stop" => {
|
"stop" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.stop().await; });
|
|
||||||
}
|
}
|
||||||
"volumeup" | "vol_up" => {
|
"volumeup" | "vol_up" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
}
|
}
|
||||||
"volumedown" | "vol_down" => {
|
"volumedown" | "vol_down" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
}
|
}
|
||||||
"mute" | "volumemute" | "togglemute" => {
|
"mute" | "volumemute" | "togglemute" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ToggleMute);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.toggle_mute().await; });
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
}
|
}
|
||||||
@@ -333,20 +325,16 @@ pub fn dispatch_button_or_code(
|
|||||||
}
|
}
|
||||||
0xF021 => {
|
0xF021 => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.stop().await; });
|
|
||||||
}
|
}
|
||||||
0xF02B => {
|
0xF02B => {
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.toggle_mute().await; });
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
}
|
}
|
||||||
0xF02C => {
|
0xF02C => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
}
|
}
|
||||||
0xF02D => {
|
0xF02D => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
}
|
}
|
||||||
@@ -437,31 +425,23 @@ pub fn dispatch_action(
|
|||||||
}
|
}
|
||||||
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
|
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.cycle_audio().await; });
|
|
||||||
}
|
}
|
||||||
"play" | "pause" | "playpause" | "15" => {
|
"play" | "pause" | "playpause" | "15" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::PlayPause);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.play_pause().await; });
|
tokio::spawn(async move { p.play_pause().await; });
|
||||||
}
|
}
|
||||||
"stop" | "14" => {
|
"stop" | "14" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.stop().await; });
|
|
||||||
}
|
}
|
||||||
"volumeup" | "vol_up" | "88" => {
|
"volumeup" | "vol_up" | "88" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
}
|
}
|
||||||
"volumedown" | "vol_down" | "89" => {
|
"volumedown" | "vol_down" | "89" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
}
|
}
|
||||||
"mute" | "togglemute" | "volumemute" | "13" => {
|
"mute" | "togglemute" | "volumemute" | "13" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ToggleMute);
|
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.toggle_mute().await; });
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ mod image_cache;
|
|||||||
mod jellyfin;
|
mod jellyfin;
|
||||||
mod mqtt;
|
mod mqtt;
|
||||||
mod player;
|
mod player;
|
||||||
mod x11_overlay;
|
|
||||||
mod yatse;
|
mod yatse;
|
||||||
mod youtube;
|
mod youtube;
|
||||||
|
|
||||||
@@ -47,7 +46,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
player: player.clone(),
|
player: player.clone(),
|
||||||
nav_tx: nav_tx.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;
|
let port = config.kodi_rpc.http_port;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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
|
// Start Kodi EventServer (UDP 9777) for Yatse
|
||||||
let event_server = KodiEventServer::new(9777, player.clone(), nav_tx);
|
let event_server = KodiEventServer::new(9777, player.clone(), nav_tx);
|
||||||
let _ = event_server.start().await;
|
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"
|
"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!({
|
Json(json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {
|
"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(
|
async fn handle_jsonrpc(
|
||||||
State(state): State<YatseServerState>,
|
State(state): State<YatseServerState>,
|
||||||
Json(req): Json<Value>,
|
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()) {
|
let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) {
|
||||||
if v_str == "increment" {
|
if v_str == "increment" {
|
||||||
state.player.volume_up().await;
|
state.player.volume_up().await;
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
state.player.status.lock().await.volume as i64
|
state.player.status.lock().await.volume as i64
|
||||||
} else if v_str == "decrement" {
|
} else if v_str == "decrement" {
|
||||||
state.player.volume_down().await;
|
state.player.volume_down().await;
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
state.player.status.lock().await.volume as i64
|
state.player.status.lock().await.volume as i64
|
||||||
} else {
|
} else {
|
||||||
state.player.status.lock().await.volume as i64
|
state.player.status.lock().await.volume as i64
|
||||||
}
|
}
|
||||||
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
|
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
|
||||||
state.player.set_volume(v).await;
|
state.player.set_volume(v).await;
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
v.clamp(0.0, 100.0) as i64
|
v.clamp(0.0, 100.0) as i64
|
||||||
} else {
|
} else {
|
||||||
state.player.status.lock().await.volume as i64
|
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})
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
}
|
}
|
||||||
"Player.SetAudioStream" => {
|
"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})
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
}
|
}
|
||||||
"Player.SetSpeed" => {
|
"Player.SetSpeed" => {
|
||||||
@@ -385,10 +549,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
if let Some(params) = req.params {
|
if let Some(params) = req.params {
|
||||||
if let Some(item) = params.get("item") {
|
if let Some(item) = params.get("item") {
|
||||||
if let Some(file) = item.get("file").and_then(|f| f.as_str()) {
|
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();
|
let p = state.player.clone();
|
||||||
tokio::spawn(async move {
|
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); }
|
"title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
|
||||||
"volumeup" | "88" => {
|
"volumeup" | "88" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
let p = state.player.clone();
|
let p = state.player.clone();
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
}
|
}
|
||||||
"volumedown" | "89" => {
|
"volumedown" | "89" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
let p = state.player.clone();
|
let p = state.player.clone();
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
}
|
}
|
||||||
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
|
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
|
||||||
"stop" | "14" => {
|
"stop" | "14" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||||
state.player.stop().await;
|
|
||||||
}
|
}
|
||||||
"mute" | "togglemute" | "13" => {
|
"mute" | "togglemute" | "13" => {
|
||||||
let p = state.player.clone();
|
let p = state.player.clone();
|
||||||
@@ -648,6 +810,112 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"GUI.ShowNotification" => {
|
"GUI.ShowNotification" => {
|
||||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
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!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -671,3 +939,28 @@ fn seconds_to_kodi_time(seconds: f64) -> Value {
|
|||||||
"milliseconds": millis
|
"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..."
|
echo "==> 📥 Downloading compiled release binary to laptop..."
|
||||||
mkdir -p "${KIOSK_DIR}/target/release"
|
mkdir -p "${KIOSK_DIR}/target/release"
|
||||||
rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${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 "==> ✅ Klaar! Binary beschikbaar in:"
|
||||||
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
|
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
|
||||||
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
|
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
|
||||||
|
|||||||
Reference in New Issue
Block a user