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;
|
||||||
|
|||||||
+517
-322
@@ -1,8 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::{Child, Command};
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::config::PlayerConfig;
|
use crate::config::PlayerConfig;
|
||||||
@@ -47,28 +46,321 @@ impl Default for PlayerStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct MpvHandle(pub *mut libmpv_sys::mpv_handle);
|
||||||
|
unsafe impl Send for MpvHandle {}
|
||||||
|
unsafe impl Sync for MpvHandle {}
|
||||||
|
|
||||||
|
impl MpvHandle {
|
||||||
|
pub fn cmd(&self, args: &[&str]) {
|
||||||
|
if self.0.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let c_strings: Vec<CString> = args.iter().filter_map(|s| CString::new(*s).ok()).collect();
|
||||||
|
let mut ptrs: Vec<*const std::ffi::c_char> = c_strings.iter().map(|c| c.as_ptr()).collect();
|
||||||
|
ptrs.push(std::ptr::null());
|
||||||
|
unsafe {
|
||||||
|
libmpv_sys::mpv_command(self.0, ptrs.as_mut_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_option_string(&self, name: &str, value: &str) {
|
||||||
|
if self.0.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Ok(n), Ok(v)) = (CString::new(name), CString::new(value)) {
|
||||||
|
unsafe {
|
||||||
|
libmpv_sys::mpv_set_option_string(self.0, n.as_ptr(), v.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct MpvRenderContext(pub *mut libmpv_sys::mpv_render_context);
|
||||||
|
unsafe impl Send for MpvRenderContext {}
|
||||||
|
unsafe impl Sync for MpvRenderContext {}
|
||||||
|
|
||||||
|
impl MpvRenderContext {
|
||||||
|
pub unsafe fn render(&self, params: &mut [libmpv_sys::mpv_render_param]) {
|
||||||
|
if !self.0.is_null() {
|
||||||
|
libmpv_sys::mpv_render_context_render(self.0, params.as_mut_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub unsafe fn report_swap(&self) {
|
||||||
|
if !self.0.is_null() {
|
||||||
|
libmpv_sys::mpv_render_context_report_swap(self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetProcAddressFn = unsafe extern "C" fn(name: *const u8) -> *mut std::ffi::c_void;
|
||||||
|
|
||||||
|
unsafe extern "C" fn get_proc_address_cb(
|
||||||
|
_ctx: *mut std::ffi::c_void,
|
||||||
|
name: *const std::ffi::c_char,
|
||||||
|
) -> *mut std::ffi::c_void {
|
||||||
|
if name.is_null() {
|
||||||
|
return std::ptr::null_mut();
|
||||||
|
}
|
||||||
|
// 1. Try glXGetProcAddressARB
|
||||||
|
let glx_name = b"glXGetProcAddressARB\0";
|
||||||
|
let glx_ptr = libc::dlsym(std::ptr::null_mut(), glx_name.as_ptr() as *const _);
|
||||||
|
if !glx_ptr.is_null() {
|
||||||
|
let glx_fn: GetProcAddressFn = std::mem::transmute(glx_ptr);
|
||||||
|
let ptr = glx_fn(name as *const u8);
|
||||||
|
if !ptr.is_null() {
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Try eglGetProcAddress
|
||||||
|
let egl_name = b"eglGetProcAddress\0";
|
||||||
|
let egl_ptr = libc::dlsym(std::ptr::null_mut(), egl_name.as_ptr() as *const _);
|
||||||
|
if !egl_ptr.is_null() {
|
||||||
|
let egl_fn: GetProcAddressFn = std::mem::transmute(egl_ptr);
|
||||||
|
let ptr = egl_fn(name as *const u8);
|
||||||
|
if !ptr.is_null() {
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Fallback: dlsym
|
||||||
|
libc::dlsym(std::ptr::null_mut(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "C" fn on_mpv_render_update(ctx_ptr: *mut std::ffi::c_void) {
|
||||||
|
if !ctx_ptr.is_null() {
|
||||||
|
let egui_ctx = &*(ctx_ptr as *const eframe::egui::Context);
|
||||||
|
egui_ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InternalMpvEvent {
|
||||||
|
StartFile,
|
||||||
|
FileLoaded,
|
||||||
|
TimePos(f64),
|
||||||
|
Duration(f64),
|
||||||
|
Pause(bool),
|
||||||
|
Volume(f64),
|
||||||
|
Mute(bool),
|
||||||
|
MediaTitle(String),
|
||||||
|
IcyTitle(String),
|
||||||
|
Metadata {
|
||||||
|
artist: Option<String>,
|
||||||
|
genre: Option<String>,
|
||||||
|
stream_title: Option<String>,
|
||||||
|
},
|
||||||
|
EndFile {
|
||||||
|
reason: i32,
|
||||||
|
error: i32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PlayerController {
|
pub struct PlayerController {
|
||||||
config: PlayerConfig,
|
_config: PlayerConfig,
|
||||||
child_process: Mutex<Option<Child>>,
|
pub mpv: MpvHandle,
|
||||||
|
pub render_ctx: Arc<std::sync::Mutex<Option<MpvRenderContext>>>,
|
||||||
pub status: Arc<Mutex<PlayerStatus>>,
|
pub status: Arc<Mutex<PlayerStatus>>,
|
||||||
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
||||||
status_rx: tokio::sync::watch::Receiver<PlayerStatus>,
|
status_rx: tokio::sync::watch::Receiver<PlayerStatus>,
|
||||||
pub window_id: Mutex<Option<u64>>,
|
pub window_id: Mutex<Option<u64>>,
|
||||||
pub ipc_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<serde_json::Value>>>,
|
is_render_initialized: AtomicBool,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
event_thread: std::sync::Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
pub fn new(config: PlayerConfig) -> Arc<Self> {
|
pub fn new(config: PlayerConfig) -> Arc<Self> {
|
||||||
let initial_status = PlayerStatus::default();
|
let initial_status = PlayerStatus::default();
|
||||||
let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone());
|
let (status_tx, status_rx) = tokio::sync::watch::channel(initial_status.clone());
|
||||||
|
|
||||||
|
let raw_mpv = unsafe { libmpv_sys::mpv_create() };
|
||||||
|
if raw_mpv.is_null() {
|
||||||
|
tracing::error!("Failed to create libmpv instance!");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mpv = MpvHandle(raw_mpv);
|
||||||
|
|
||||||
|
// Configure MPV options
|
||||||
|
mpv.set_option_string("vo", "libmpv");
|
||||||
|
mpv.set_option_string("hwdec", &config.hwdec);
|
||||||
|
mpv.set_option_string("idle", "yes");
|
||||||
|
mpv.set_option_string("keep-open", "yes");
|
||||||
|
mpv.set_option_string("osd-bar", "no");
|
||||||
|
mpv.set_option_string("osd-level", "0");
|
||||||
|
mpv.set_option_string("ytdl", "yes");
|
||||||
|
|
||||||
|
let ytdlp_path = if std::path::Path::new("/usr/local/bin/yt-dlp").exists() {
|
||||||
|
"/usr/local/bin/yt-dlp"
|
||||||
|
} else if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
|
||||||
|
"/home/bart/.local/bin/yt-dlp"
|
||||||
|
} else {
|
||||||
|
"yt-dlp"
|
||||||
|
};
|
||||||
|
mpv.set_option_string("script-opts", &format!("ytdl_hook-ytdl_path={}", ytdlp_path));
|
||||||
|
mpv.set_option_string("ytdl-format", "bestvideo[height<=?1080]+bestaudio/best");
|
||||||
|
mpv.set_option_string("ytdl-raw-options", "no-playlist=");
|
||||||
|
|
||||||
|
let init_res = unsafe { libmpv_sys::mpv_initialize(raw_mpv) };
|
||||||
|
if init_res != 0 {
|
||||||
|
tracing::warn!("mpv_initialize returned error code: {}", init_res);
|
||||||
|
} else {
|
||||||
|
tracing::info!("libmpv initialized successfully (vo=libmpv, hwdec={})", config.hwdec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe properties
|
||||||
|
unsafe {
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 1, b"time-pos\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 2, b"duration\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 3, b"pause\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 4, b"volume\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 5, b"media-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 6, b"icy-title\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_STRING);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 7, b"mute\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_FLAG);
|
||||||
|
libmpv_sys::mpv_observe_property(raw_mpv, 8, b"metadata\0".as_ptr() as *const _, libmpv_sys::mpv_format_MPV_FORMAT_NODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status_arc = Arc::new(Mutex::new(initial_status));
|
||||||
|
let render_ctx = Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let is_running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
// Create internal event channel to decouple MPV C thread from Tokio async tasks
|
||||||
|
let (ev_tx, mut ev_rx) = tokio::sync::mpsc::unbounded_channel::<InternalMpvEvent>();
|
||||||
|
|
||||||
|
// Spawn Tokio consumer task
|
||||||
|
let status_consumer = status_arc.clone();
|
||||||
|
let status_tx_consumer = status_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(event) = ev_rx.recv().await {
|
||||||
|
let mut st = status_consumer.lock().await;
|
||||||
|
let mut notify = false;
|
||||||
|
match event {
|
||||||
|
InternalMpvEvent::StartFile | InternalMpvEvent::FileLoaded => {
|
||||||
|
st.state = PlaybackState::Playing;
|
||||||
|
st.error_message = None;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
InternalMpvEvent::TimePos(pos) => {
|
||||||
|
st.time_pos = pos;
|
||||||
|
}
|
||||||
|
InternalMpvEvent::Duration(dur) => {
|
||||||
|
st.duration = dur;
|
||||||
|
}
|
||||||
|
InternalMpvEvent::Pause(paused) => {
|
||||||
|
let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing };
|
||||||
|
if st.state != new_state && st.state != PlaybackState::Stopped {
|
||||||
|
st.state = new_state;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::Volume(vol) => {
|
||||||
|
if (st.volume - vol).abs() > 0.01 {
|
||||||
|
st.volume = vol;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::Mute(m) => {
|
||||||
|
if st.is_muted != m {
|
||||||
|
st.is_muted = m;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::MediaTitle(mt) => {
|
||||||
|
if st.media_type == "stream" {
|
||||||
|
if st.stream_title.is_empty() || st.stream_title != mt {
|
||||||
|
st.stream_title = mt;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
} else if st.title.is_empty() {
|
||||||
|
st.title = mt;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::IcyTitle(icy) => {
|
||||||
|
if st.stream_title != icy {
|
||||||
|
st.stream_title = icy;
|
||||||
|
st.media_type = "stream".to_string();
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::Metadata { artist, genre, stream_title } => {
|
||||||
|
if let Some(a) = artist {
|
||||||
|
if st.artist != a {
|
||||||
|
st.artist = a;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(g) = genre {
|
||||||
|
if st.genre != g {
|
||||||
|
st.genre = g;
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(s) = stream_title {
|
||||||
|
if st.stream_title != s {
|
||||||
|
st.stream_title = s;
|
||||||
|
st.media_type = "stream".to_string();
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InternalMpvEvent::EndFile { reason, error } => {
|
||||||
|
if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_ERROR as i32 {
|
||||||
|
let err_msg = if error != 0 {
|
||||||
|
let msg = unsafe {
|
||||||
|
let p = libmpv_sys::mpv_error_string(error);
|
||||||
|
if !p.is_null() {
|
||||||
|
CStr::from_ptr(p).to_str().unwrap_or("Afspeelfout")
|
||||||
|
} else {
|
||||||
|
"Afspeelfout"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
format!("Afspeelfout: {}", msg)
|
||||||
|
} else {
|
||||||
|
"Afspeelfout".to_string()
|
||||||
|
};
|
||||||
|
tracing::warn!("MPV playback error: {}", err_msg);
|
||||||
|
st.state = PlaybackState::Stopped;
|
||||||
|
st.error_message = Some(err_msg);
|
||||||
|
notify = true;
|
||||||
|
} else if reason == libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32 {
|
||||||
|
st.state = PlaybackState::Stopped;
|
||||||
|
st.time_pos = 0.0;
|
||||||
|
st.media_type.clear();
|
||||||
|
st.title.clear();
|
||||||
|
st.artist.clear();
|
||||||
|
st.genre.clear();
|
||||||
|
st.stream_title.clear();
|
||||||
|
notify = true;
|
||||||
|
}
|
||||||
|
// Note: If reason == STOP (2) or REDIRECT (5), this was triggered by loadfile replacing
|
||||||
|
// the previous file, so we intentionally keep the new file playing!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if notify {
|
||||||
|
let _ = status_tx_consumer.send(st.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn background event thread for MPV
|
||||||
|
let mpv_for_thread = mpv;
|
||||||
|
let is_running_for_thread = is_running.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
run_mpv_event_loop(mpv_for_thread, is_running_for_thread, ev_tx);
|
||||||
|
});
|
||||||
|
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
config,
|
_config: config,
|
||||||
child_process: Mutex::new(None),
|
mpv,
|
||||||
status: Arc::new(Mutex::new(initial_status)),
|
render_ctx,
|
||||||
|
status: status_arc,
|
||||||
status_tx,
|
status_tx,
|
||||||
status_rx,
|
status_rx,
|
||||||
window_id: Mutex::new(None),
|
window_id: Mutex::new(None),
|
||||||
ipc_tx: Mutex::new(None),
|
is_render_initialized: AtomicBool::new(false),
|
||||||
|
is_running,
|
||||||
|
event_thread: std::sync::Mutex::new(Some(handle)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +376,54 @@ impl PlayerController {
|
|||||||
pub async fn set_window_id(&self, wid: Option<u64>) {
|
pub async fn set_window_id(&self, wid: Option<u64>) {
|
||||||
let mut guard = self.window_id.lock().await;
|
let mut guard = self.window_id.lock().await;
|
||||||
*guard = wid;
|
*guard = wid;
|
||||||
if let Some(w) = wid {
|
}
|
||||||
tracing::info!("Registered window handle for MPV embedding: {}", w);
|
|
||||||
|
pub fn render_context(&self) -> Option<MpvRenderContext> {
|
||||||
|
*self.render_ctx.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_render_context(&self, ctx: &eframe::egui::Context) {
|
||||||
|
if self.is_render_initialized.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mpv.0.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let mut gl_init = libmpv_sys::mpv_opengl_init_params {
|
||||||
|
get_proc_address: Some(get_proc_address_cb),
|
||||||
|
get_proc_address_ctx: std::ptr::null_mut(),
|
||||||
|
extra_exts: std::ptr::null(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut params = [
|
||||||
|
libmpv_sys::mpv_render_param {
|
||||||
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
|
||||||
|
data: libmpv_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut _,
|
||||||
|
},
|
||||||
|
libmpv_sys::mpv_render_param {
|
||||||
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
|
||||||
|
data: &mut gl_init as *mut _ as *mut _,
|
||||||
|
},
|
||||||
|
libmpv_sys::mpv_render_param {
|
||||||
|
type_: libmpv_sys::mpv_render_param_type_MPV_RENDER_PARAM_INVALID,
|
||||||
|
data: std::ptr::null_mut(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut rc = std::ptr::null_mut();
|
||||||
|
let res = libmpv_sys::mpv_render_context_create(&mut rc, self.mpv.0, params.as_mut_ptr());
|
||||||
|
if res == 0 {
|
||||||
|
tracing::info!("libmpv OpenGL render context created successfully!");
|
||||||
|
let ctx_ptr = Box::into_raw(Box::new(ctx.clone())) as *mut std::ffi::c_void;
|
||||||
|
libmpv_sys::mpv_render_context_set_update_callback(rc, Some(on_mpv_render_update), ctx_ptr);
|
||||||
|
*self.render_ctx.lock().unwrap() = Some(MpvRenderContext(rc));
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Failed to initialize libmpv OpenGL render context (code: {})", res);
|
||||||
|
self.is_render_initialized.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,52 +460,6 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
|
|
||||||
// Clean up socket file if exists
|
|
||||||
let sock_path = std::path::Path::new(&self.config.ipc_socket);
|
|
||||||
if sock_path.exists() {
|
|
||||||
let _ = std::fs::remove_file(sock_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill any existing instance
|
|
||||||
{
|
|
||||||
let mut proc_guard = self.child_process.lock().await;
|
|
||||||
if let Some(mut child) = proc_guard.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build MPV command
|
|
||||||
let mut cmd = Command::new(&self.config.mpv_path);
|
|
||||||
cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket));
|
|
||||||
cmd.arg(format!("--hwdec={}", self.config.hwdec));
|
|
||||||
cmd.arg(format!("--vo={}", self.config.vo));
|
|
||||||
cmd.arg("--osd-bar=yes");
|
|
||||||
cmd.arg("--osd-level=1");
|
|
||||||
cmd.arg("--osd-font-size=28");
|
|
||||||
cmd.arg("--osd-on-seek=msg-bar");
|
|
||||||
cmd.arg("--input-vo-keyboard=no");
|
|
||||||
cmd.arg("--input-cursor=no");
|
|
||||||
cmd.arg("--input-cursor-passthrough=yes");
|
|
||||||
|
|
||||||
// Configure yt-dlp for YouTube streams
|
|
||||||
let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
|
|
||||||
"/home/bart/.local/bin/yt-dlp"
|
|
||||||
} else {
|
|
||||||
"yt-dlp"
|
|
||||||
};
|
|
||||||
cmd.arg(format!("--script-opts=ytdl_hook-ytdl_path={}", ytdlp_path));
|
|
||||||
cmd.arg("--ytdl-format=bestvideo[height<=?1080]+bestaudio/best");
|
|
||||||
cmd.arg("--ytdl-raw-options=no-playlist=");
|
|
||||||
|
|
||||||
let wid = *self.window_id.lock().await;
|
|
||||||
if let Some(w) = wid {
|
|
||||||
tracing::info!("Embedding MPV directly into window ID: {}", w);
|
|
||||||
cmd.arg(format!("--wid={}", w));
|
|
||||||
} else if self.config.fullscreen {
|
|
||||||
cmd.arg("--fs");
|
|
||||||
}
|
|
||||||
cmd.arg(format!("--title={}", title));
|
|
||||||
|
|
||||||
// Strip playlist/index params from single YouTube video playback
|
// Strip playlist/index params from single YouTube video playback
|
||||||
let play_url = if url.contains("youtube.com") || url.contains("youtu.be") {
|
let play_url = if url.contains("youtube.com") || url.contains("youtu.be") {
|
||||||
let mut u = url.to_string();
|
let mut u = url.to_string();
|
||||||
@@ -181,71 +473,20 @@ impl PlayerController {
|
|||||||
} else {
|
} else {
|
||||||
url.to_string()
|
url.to_string()
|
||||||
};
|
};
|
||||||
cmd.arg(play_url);
|
|
||||||
|
|
||||||
match cmd.spawn() {
|
|
||||||
Ok(child) => {
|
|
||||||
let mut proc_guard = self.child_process.lock().await;
|
|
||||||
*proc_guard = Some(child);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let msg = format!("MPV niet gevonden ('{}')! Installeer MPV via: sudo apt install mpv", self.config.mpv_path);
|
|
||||||
tracing::warn!("{}: {}", msg, e);
|
|
||||||
let mut st = self.status.lock().await;
|
|
||||||
st.state = PlaybackState::Stopped;
|
|
||||||
st.error_message = Some(msg);
|
|
||||||
return Err(format!("Could not launch MPV: {}", e).into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create persistent IPC command channel
|
|
||||||
let (ipc_tx, ipc_rx) = tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
|
|
||||||
{
|
|
||||||
let mut guard = self.ipc_tx.lock().await;
|
|
||||||
*guard = Some(ipc_tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start background IPC monitoring task
|
|
||||||
let sock_file = self.config.ipc_socket.clone();
|
|
||||||
let status_arc = self.status.clone();
|
|
||||||
let status_tx = self.status_tx.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx, ipc_rx).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Always reset video and audio track selection to auto so subsequent content plays normally
|
||||||
|
self.mpv.cmd(&["set", "vid", "auto"]);
|
||||||
|
self.mpv.cmd(&["set", "aid", "auto"]);
|
||||||
|
self.mpv.cmd(&["loadfile", &play_url, "replace"]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn play_pause(&self) {
|
pub async fn play_pause(&self) {
|
||||||
let current_state = {
|
self.mpv.cmd(&["cycle", "pause"]);
|
||||||
let st = self.status.lock().await;
|
|
||||||
st.state
|
|
||||||
};
|
|
||||||
|
|
||||||
let new_state = match current_state {
|
|
||||||
PlaybackState::Playing => PlaybackState::Paused,
|
|
||||||
PlaybackState::Paused => PlaybackState::Playing,
|
|
||||||
PlaybackState::Stopped => PlaybackState::Stopped,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg", "cycle", "pause"]).await;
|
|
||||||
{
|
|
||||||
let mut st = self.status.lock().await;
|
|
||||||
st.state = new_state;
|
|
||||||
}
|
|
||||||
self.notify_status().await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) {
|
pub async fn stop(&self) {
|
||||||
let _ = self.send_ipc_cmd(&["stop"]).await;
|
self.mpv.cmd(&["stop"]);
|
||||||
{
|
|
||||||
let mut guard = self.ipc_tx.lock().await;
|
|
||||||
*guard = None;
|
|
||||||
}
|
|
||||||
let mut proc_guard = self.child_process.lock().await;
|
|
||||||
if let Some(mut child) = proc_guard.take() {
|
|
||||||
let _ = child.kill();
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.state = PlaybackState::Stopped;
|
st.state = PlaybackState::Stopped;
|
||||||
@@ -261,28 +502,26 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn seek(&self, seconds: f64) {
|
pub async fn seek(&self, seconds: f64) {
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &seconds.to_string(), "relative"]).await;
|
self.mpv.cmd(&["seek", &seconds.to_string(), "relative"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn seek_percent(&self, percent: f64) {
|
pub async fn seek_percent(&self, percent: f64) {
|
||||||
let p = percent.clamp(0.0, 100.0);
|
let p = percent.clamp(0.0, 100.0);
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &p.to_string(), "absolute-percent"]).await;
|
self.mpv.cmd(&["seek", &p.to_string(), "absolute-percent"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn seek_time(&self, seconds: f64) {
|
pub async fn seek_time(&self, seconds: f64) {
|
||||||
let s = seconds.max(0.0);
|
let s = seconds.max(0.0);
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &s.to_string(), "absolute"]).await;
|
self.mpv.cmd(&["seek", &s.to_string(), "absolute"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn toggle_mute(&self) {
|
pub async fn toggle_mute(&self) {
|
||||||
let new_muted = {
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.is_muted = !st.is_muted;
|
st.is_muted = !st.is_muted;
|
||||||
st.is_muted
|
}
|
||||||
};
|
self.mpv.cmd(&["cycle", "mute"]);
|
||||||
let _ = self.send_ipc_cmd(&["cycle", "mute"]).await;
|
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player mute toggled to {}", new_muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_mute(&self, muted: bool) {
|
pub async fn set_mute(&self, muted: bool) {
|
||||||
@@ -290,10 +529,8 @@ impl PlayerController {
|
|||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.is_muted = muted;
|
st.is_muted = muted;
|
||||||
}
|
}
|
||||||
let arg = if muted { "yes" } else { "no" };
|
self.mpv.cmd(&["set", "mute", if muted { "yes" } else { "no" }]);
|
||||||
let _ = self.send_ipc_cmd(&["set", "mute", arg]).await;
|
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player mute set to {}", muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_volume(&self, volume: f64) {
|
pub async fn set_volume(&self, volume: f64) {
|
||||||
@@ -302,266 +539,224 @@ impl PlayerController {
|
|||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.volume = vol;
|
st.volume = vol;
|
||||||
}
|
}
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "set", "volume", &vol.to_string()]).await;
|
self.mpv.cmd(&["set", "volume", &vol.to_string()]);
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player volume set to {}", vol);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn volume_up(&self) {
|
pub async fn volume_up(&self) {
|
||||||
let new_vol = {
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.volume = (st.volume + 5.0).min(100.0);
|
st.volume = (st.volume + 5.0).min(100.0);
|
||||||
st.volume
|
}
|
||||||
};
|
self.mpv.cmd(&["add", "volume", "5"]);
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "5"]).await;
|
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player volume increased to {}", new_vol);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn volume_down(&self) {
|
pub async fn volume_down(&self) {
|
||||||
let new_vol = {
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.volume = (st.volume - 5.0).max(0.0);
|
st.volume = (st.volume - 5.0).max(0.0);
|
||||||
st.volume
|
}
|
||||||
};
|
self.mpv.cmd(&["add", "volume", "-5"]);
|
||||||
let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "-5"]).await;
|
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player volume decreased to {}", new_vol);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cycle_subtitle(&self) {
|
pub async fn cycle_subtitle(&self) {
|
||||||
let _ = self.send_ipc_cmd(&["cycle", "sub"]).await;
|
self.mpv.cmd(&["cycle", "sub"]);
|
||||||
let _ = self.send_ipc_cmd(&[
|
|
||||||
"expand-properties",
|
|
||||||
"show-text",
|
|
||||||
"💬 Ondertitels: ${current-tracks/sub/title:${current-tracks/sub/lang:${sub:Geen beschikbaar}}}",
|
|
||||||
"2500",
|
|
||||||
]).await;
|
|
||||||
tracing::info!("Cycled subtitle track");
|
tracing::info!("Cycled subtitle track");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cycle_audio(&self) {
|
pub async fn cycle_audio(&self) {
|
||||||
let _ = self.send_ipc_cmd(&["cycle", "audio"]).await;
|
self.mpv.cmd(&["cycle", "audio"]);
|
||||||
let _ = self.send_ipc_cmd(&[
|
|
||||||
"expand-properties",
|
|
||||||
"show-text",
|
|
||||||
"🎬 Audio: ${current-tracks/audio/title:${current-tracks/audio/lang:${audio:Standaard}}}",
|
|
||||||
"2500",
|
|
||||||
]).await;
|
|
||||||
tracing::info!("Cycled audio track");
|
tracing::info!("Cycled audio track");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_video_visible(&self, visible: bool) {
|
pub async fn set_audio_track(&self, track_id: i32) {
|
||||||
let arg = if visible { "auto" } else { "no" };
|
self.mpv.cmd(&["set", "aid", &track_id.to_string()]);
|
||||||
let _ = self.send_ipc_cmd(&["set", "vid", arg]).await;
|
tracing::info!("Set audio track to aid={}", track_id);
|
||||||
tracing::info!("Set MPV video visibility to {}", visible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn set_video_visible(&self, _visible: bool) {
|
||||||
let guard = self.ipc_tx.lock().await;
|
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
|
||||||
if let Some(ref tx) = *guard {
|
|
||||||
let cmd_json = serde_json::json!({
|
|
||||||
"command": args
|
|
||||||
});
|
|
||||||
let _ = tx.send(cmd_json);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err("MPV IPC not connected".into())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn monitor_mpv_ipc(
|
impl Drop for PlayerController {
|
||||||
sock_path: String,
|
fn drop(&mut self) {
|
||||||
status: Arc<Mutex<PlayerStatus>>,
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
if !self.mpv.0.is_null() {
|
||||||
mut ipc_rx: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
|
unsafe {
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
libmpv_sys::mpv_wakeup(self.mpv.0);
|
||||||
// Wait for socket to become available
|
}
|
||||||
for _ in 0..30 {
|
}
|
||||||
if std::path::Path::new(&sock_path).exists() {
|
if let Ok(mut guard) = self.event_thread.lock() {
|
||||||
|
if let Some(handle) = guard.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rc_opt = self.render_ctx.lock().unwrap().take();
|
||||||
|
if let Some(rc) = rc_opt {
|
||||||
|
unsafe {
|
||||||
|
libmpv_sys::mpv_render_context_free(rc.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.mpv.0.is_null() {
|
||||||
|
unsafe {
|
||||||
|
libmpv_sys::mpv_destroy(self.mpv.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_mpv_event_loop(
|
||||||
|
mpv: MpvHandle,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
event_tx: tokio::sync::mpsc::UnboundedSender<InternalMpvEvent>,
|
||||||
|
) {
|
||||||
|
if mpv.0.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while is_running.load(Ordering::SeqCst) {
|
||||||
|
let ev = unsafe { libmpv_sys::mpv_wait_event(mpv.0, 0.05) };
|
||||||
|
if !is_running.load(Ordering::SeqCst) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
if ev.is_null() {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = match UnixStream::connect(&sock_path).await {
|
let event_id = unsafe { (*ev).event_id };
|
||||||
Ok(s) => s,
|
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_SHUTDOWN {
|
||||||
Err(_) => return Ok(()),
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_START_FILE {
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::StartFile);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_FILE_LOADED {
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::FileLoaded);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_END_FILE {
|
||||||
|
let end_data = unsafe { (*ev).data as *const libmpv_sys::mpv_event_end_file };
|
||||||
|
let (reason, error) = if !end_data.is_null() {
|
||||||
|
unsafe { ((*end_data).reason, (*end_data).error) }
|
||||||
|
} else {
|
||||||
|
(libmpv_sys::mpv_end_file_reason_MPV_END_FILE_REASON_EOF as i32, 0)
|
||||||
|
};
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::EndFile { reason, error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_id == libmpv_sys::mpv_event_id_MPV_EVENT_PROPERTY_CHANGE {
|
||||||
|
let prop = unsafe { (*ev).data as *const libmpv_sys::mpv_event_property };
|
||||||
|
if prop.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prop_name = unsafe {
|
||||||
|
if (*prop).name.is_null() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
CStr::from_ptr((*prop).name).to_str().unwrap_or("")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (reader, mut writer) = stream.into_split();
|
match prop_name {
|
||||||
let mut lines = BufReader::new(reader).lines();
|
|
||||||
|
|
||||||
// Observe properties
|
|
||||||
let observe_cmds = [
|
|
||||||
r#"{"command":["observe_property",1,"time-pos"]}"#,
|
|
||||||
r#"{"command":["observe_property",2,"duration"]}"#,
|
|
||||||
r#"{"command":["observe_property",3,"pause"]}"#,
|
|
||||||
r#"{"command":["observe_property",4,"volume"]}"#,
|
|
||||||
r#"{"command":["observe_property",5,"media-title"]}"#,
|
|
||||||
r#"{"command":["observe_property",6,"icy-title"]}"#,
|
|
||||||
r#"{"command":["observe_property",7,"metadata"]}"#,
|
|
||||||
r#"{"command":["observe_property",8,"mute"]}"#,
|
|
||||||
];
|
|
||||||
|
|
||||||
for c in &observe_cmds {
|
|
||||||
let _ = writer.write_all(format!("{}\n", c).as_bytes()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
cmd_opt = ipc_rx.recv() => {
|
|
||||||
match cmd_opt {
|
|
||||||
Some(cmd_val) => {
|
|
||||||
let payload = format!("{}\n", cmd_val);
|
|
||||||
if let Err(e) = writer.write_all(payload.as_bytes()).await {
|
|
||||||
tracing::warn!("MPV IPC write error: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
line_res = lines.next_line() => {
|
|
||||||
match line_res {
|
|
||||||
Ok(Some(line)) => {
|
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
|
|
||||||
if let Some(event) = v.get("event").and_then(|e| e.as_str()) {
|
|
||||||
if event == "property-change" {
|
|
||||||
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
|
||||||
let data = v.get("data");
|
|
||||||
|
|
||||||
let mut st = status.lock().await;
|
|
||||||
let mut notify = false;
|
|
||||||
match name {
|
|
||||||
"mute" => {
|
|
||||||
if let Some(m) = data.and_then(|d| d.as_bool()) {
|
|
||||||
if st.is_muted != m {
|
|
||||||
st.is_muted = m;
|
|
||||||
notify = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"time-pos" => {
|
"time-pos" => {
|
||||||
if let Some(pos) = data.and_then(|d| d.as_f64()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||||
st.time_pos = pos;
|
let pos = unsafe { *((*prop).data as *const f64) };
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::TimePos(pos));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"duration" => {
|
"duration" => {
|
||||||
if let Some(dur) = data.and_then(|d| d.as_f64()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||||
st.duration = dur;
|
let dur = unsafe { *((*prop).data as *const f64) };
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::Duration(dur));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"pause" => {
|
"pause" => {
|
||||||
if let Some(paused) = data.and_then(|d| d.as_bool()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||||
let new_state = if paused { PlaybackState::Paused } else { PlaybackState::Playing };
|
let paused = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||||
if st.state != new_state {
|
let _ = event_tx.send(InternalMpvEvent::Pause(paused));
|
||||||
st.state = new_state;
|
|
||||||
notify = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"volume" => {
|
"volume" => {
|
||||||
if let Some(vol) = data.and_then(|d| d.as_f64()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_DOUBLE } && !unsafe { (*prop).data.is_null() } {
|
||||||
if (st.volume - vol).abs() > 0.01 {
|
let vol = unsafe { *((*prop).data as *const f64) };
|
||||||
st.volume = vol;
|
let _ = event_tx.send(InternalMpvEvent::Volume(vol));
|
||||||
notify = true;
|
}
|
||||||
|
}
|
||||||
|
"mute" => {
|
||||||
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_FLAG } && !unsafe { (*prop).data.is_null() } {
|
||||||
|
let m = unsafe { *((*prop).data as *const std::ffi::c_int) != 0 };
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::Mute(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"media-title" => {
|
||||||
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
|
||||||
|
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
|
||||||
|
if !str_ptr.is_null() {
|
||||||
|
let mt = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
|
||||||
|
if !mt.is_empty() {
|
||||||
|
let _ = event_tx.send(InternalMpvEvent::MediaTitle(mt.to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"icy-title" => {
|
"icy-title" => {
|
||||||
if let Some(icy) = data.and_then(|d| d.as_str()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING } && !unsafe { (*prop).data.is_null() } {
|
||||||
let trimmed = icy.trim();
|
let str_ptr = unsafe { *((*prop).data as *const *const std::ffi::c_char) };
|
||||||
if !trimmed.is_empty() && st.stream_title != trimmed {
|
if !str_ptr.is_null() {
|
||||||
tracing::info!("MPV ICY Stream title detected: {}", trimmed);
|
let icy = unsafe { CStr::from_ptr(str_ptr).to_str().unwrap_or("") }.trim();
|
||||||
st.stream_title = trimmed.to_string();
|
if !icy.is_empty() {
|
||||||
if st.media_type.is_empty() || st.media_type != "stream" {
|
let _ = event_tx.send(InternalMpvEvent::IcyTitle(icy.to_string()));
|
||||||
st.media_type = "stream".to_string();
|
|
||||||
}
|
|
||||||
notify = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"media-title" => {
|
|
||||||
if let Some(mt) = data.and_then(|d| d.as_str()) {
|
|
||||||
let trimmed = mt.trim();
|
|
||||||
if !trimmed.is_empty() {
|
|
||||||
if st.media_type == "stream" {
|
|
||||||
if st.stream_title.is_empty() || st.stream_title != trimmed {
|
|
||||||
tracing::info!("MPV Stream media-title: {}", trimmed);
|
|
||||||
st.stream_title = trimmed.to_string();
|
|
||||||
notify = true;
|
|
||||||
}
|
|
||||||
} else if st.title.is_empty() {
|
|
||||||
st.title = trimmed.to_string();
|
|
||||||
notify = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"metadata" => {
|
"metadata" => {
|
||||||
if let Some(meta_obj) = data.and_then(|d| d.as_object()) {
|
if unsafe { (*prop).format == libmpv_sys::mpv_format_MPV_FORMAT_NODE } && !unsafe { (*prop).data.is_null() } {
|
||||||
let stream_t = meta_obj.get("icy-title")
|
let node = unsafe { &*((*prop).data as *const libmpv_sys::mpv_node) };
|
||||||
.or_else(|| meta_obj.get("icy_title"))
|
if node.format == libmpv_sys::mpv_format_MPV_FORMAT_NODE_MAP {
|
||||||
.or_else(|| meta_obj.get("StreamTitle"))
|
let mut artist = None;
|
||||||
.or_else(|| meta_obj.get("title"))
|
let mut genre = None;
|
||||||
.or_else(|| meta_obj.get("Title"))
|
let mut stream_title = None;
|
||||||
.and_then(|val| val.as_str());
|
unsafe {
|
||||||
|
let list = &*node.u.list;
|
||||||
if let Some(s) = stream_t {
|
for i in 0..list.num {
|
||||||
let trimmed = s.trim();
|
let key_ptr = *list.keys.offset(i as isize);
|
||||||
if !trimmed.is_empty() && (st.media_type == "stream" || meta_obj.contains_key("icy-title")) {
|
let val_node = list.values.offset(i as isize);
|
||||||
if st.stream_title != trimmed {
|
if !key_ptr.is_null() && !val_node.is_null() && (*val_node).format == libmpv_sys::mpv_format_MPV_FORMAT_STRING {
|
||||||
st.stream_title = trimmed.to_string();
|
let key = CStr::from_ptr(key_ptr).to_str().unwrap_or("");
|
||||||
st.media_type = "stream".to_string();
|
let val_ptr = (*val_node).u.string;
|
||||||
notify = true;
|
if !val_ptr.is_null() {
|
||||||
|
let val = CStr::from_ptr(val_ptr).to_str().unwrap_or("").trim();
|
||||||
|
if !val.is_empty() {
|
||||||
|
if key.eq_ignore_ascii_case("artist") {
|
||||||
|
artist = Some(val.to_string());
|
||||||
|
} else if key.eq_ignore_ascii_case("genre") {
|
||||||
|
genre = Some(val.to_string());
|
||||||
|
} else if key.eq_ignore_ascii_case("icy-title") || key.eq_ignore_ascii_case("StreamTitle") {
|
||||||
|
stream_title = Some(val.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(a) = meta_obj.get("artist").or_else(|| meta_obj.get("Artist")).and_then(|val| val.as_str()) {
|
|
||||||
let trimmed = a.trim();
|
|
||||||
if !trimmed.is_empty() && st.artist != trimmed {
|
|
||||||
st.artist = trimmed.to_string();
|
|
||||||
notify = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if let Some(g) = meta_obj.get("genre").or_else(|| meta_obj.get("Genre")).or_else(|| meta_obj.get("icy-genre")).and_then(|val| val.as_str()) {
|
if artist.is_some() || genre.is_some() || stream_title.is_some() {
|
||||||
let trimmed = g.trim();
|
let _ = event_tx.send(InternalMpvEvent::Metadata { artist, genre, stream_title });
|
||||||
if !trimmed.is_empty() && st.genre != trimmed {
|
|
||||||
st.genre = trimmed.to_string();
|
|
||||||
notify = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if notify {
|
|
||||||
let _ = status_tx.send(st.clone());
|
|
||||||
}
|
|
||||||
} else if event == "end-file" {
|
|
||||||
let mut st = status.lock().await;
|
|
||||||
st.state = PlaybackState::Stopped;
|
|
||||||
st.media_type.clear();
|
|
||||||
st.title.clear();
|
|
||||||
st.artist.clear();
|
|
||||||
st.genre.clear();
|
|
||||||
st.stream_title.clear();
|
|
||||||
st.time_pos = 0.0;
|
|
||||||
st.duration = 0.0;
|
|
||||||
let _ = status_tx.send(st.clone());
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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" => {
|
||||||
|
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;
|
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