new
This commit is contained in:
Binary file not shown.
+1064
-433
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
use tokio::sync::mpsc::UnboundedSender;
|
|
||||||
|
|
||||||
use crate::player::PlayerController;
|
use crate::player::PlayerController;
|
||||||
use crate::yatse::RemoteNavAction;
|
use crate::yatse::{RemoteNavAction, RemoteNavSender};
|
||||||
|
|
||||||
// Official Kodi EventServer Packet Types
|
// Official Kodi EventServer Packet Types
|
||||||
pub const PT_HELO: u16 = 0x01;
|
pub const PT_HELO: u16 = 0x01;
|
||||||
@@ -27,12 +26,12 @@ pub const HEADER_SIZE: usize = 32;
|
|||||||
pub struct KodiEventServer {
|
pub struct KodiEventServer {
|
||||||
port: u16,
|
port: u16,
|
||||||
player: Arc<PlayerController>,
|
player: Arc<PlayerController>,
|
||||||
nav_tx: UnboundedSender<RemoteNavAction>,
|
nav_tx: RemoteNavSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KodiEventServer {
|
impl KodiEventServer {
|
||||||
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: UnboundedSender<RemoteNavAction>) -> Self {
|
pub fn new(port: u16, player: Arc<PlayerController>, nav_tx: impl Into<RemoteNavSender>) -> Self {
|
||||||
Self { port, player, nav_tx }
|
Self { port, player, nav_tx: nav_tx.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<u16, std::io::Error> {
|
pub async fn start(&self) -> Result<u16, std::io::Error> {
|
||||||
@@ -248,7 +247,7 @@ pub fn build_action_packet(action_str: &str, token: u32) -> Vec<u8> {
|
|||||||
pub fn dispatch_button_or_code(
|
pub fn dispatch_button_or_code(
|
||||||
btn: &str,
|
btn: &str,
|
||||||
code: u16,
|
code: u16,
|
||||||
nav_tx: &UnboundedSender<RemoteNavAction>,
|
nav_tx: &RemoteNavSender,
|
||||||
player: &Arc<PlayerController>,
|
player: &Arc<PlayerController>,
|
||||||
) {
|
) {
|
||||||
let lower = btn.to_lowercase();
|
let lower = btn.to_lowercase();
|
||||||
@@ -271,16 +270,28 @@ pub fn dispatch_button_or_code(
|
|||||||
"back" | "parentdir" | "previousmenu" | "escape" => {
|
"back" | "parentdir" | "previousmenu" | "escape" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Back);
|
let _ = nav_tx.send(RemoteNavAction::Back);
|
||||||
}
|
}
|
||||||
"title" | "menu" | "topmenu" | "contextmenu" => {
|
"title" | "topmenu" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||||
}
|
}
|
||||||
"togglefullscreen" | "fullscreen" => {
|
"menu" | "contextmenu" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
}
|
}
|
||||||
"osd" | "showosd" | "subtitles" | "nextsubtitle" => {
|
"togglefullscreen" | "fullscreen" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||||
|
}
|
||||||
|
"osd" | "showosd" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
|
}
|
||||||
|
"subtitles" | "nextsubtitle" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||||
}
|
}
|
||||||
|
"audionextlanguage" | "nextaudio" | "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; });
|
||||||
}
|
}
|
||||||
@@ -299,10 +310,14 @@ pub fn dispatch_button_or_code(
|
|||||||
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" => {
|
"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; });
|
||||||
}
|
}
|
||||||
|
"info" | "showcodecinfo" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Fallback to raw keycode if button string was not matched
|
// Fallback to raw keycode if button string was not matched
|
||||||
match code {
|
match code {
|
||||||
@@ -312,62 +327,158 @@ pub fn dispatch_button_or_code(
|
|||||||
0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); }
|
0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); }
|
||||||
0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); }
|
0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); }
|
||||||
0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); }
|
0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); }
|
||||||
|
0xF020 | 0xF022 => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.play_pause().await; });
|
||||||
|
}
|
||||||
|
0xF021 => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.stop().await; });
|
||||||
|
}
|
||||||
|
0xF02B => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
|
}
|
||||||
|
0xF02C => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
|
}
|
||||||
|
0xF02D => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn normalize_action(action: &str) -> String {
|
||||||
|
let mut lower = action.trim().to_lowercase();
|
||||||
|
if lower.starts_with("action(") && lower.ends_with(')') {
|
||||||
|
lower = lower[7..lower.len() - 1].trim().to_string();
|
||||||
|
}
|
||||||
|
if lower.ends_with("()") {
|
||||||
|
lower = lower[..lower.len() - 2].trim().to_string();
|
||||||
|
}
|
||||||
|
lower
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dispatch_action(
|
pub fn dispatch_action(
|
||||||
action: &str,
|
action: &str,
|
||||||
nav_tx: &UnboundedSender<RemoteNavAction>,
|
nav_tx: &RemoteNavSender,
|
||||||
player: &Arc<PlayerController>,
|
player: &Arc<PlayerController>,
|
||||||
) {
|
) {
|
||||||
let lower = action.to_lowercase();
|
let lower = action.trim().to_lowercase();
|
||||||
if lower == "up" || lower == "action(3)" || lower == "cursorup" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::Up);
|
// Check window activation first
|
||||||
} else if lower == "down" || lower == "action(4)" || lower == "cursordown" {
|
if lower.starts_with("activatewindow") {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Down);
|
if lower.contains("tvshowtitles") || lower.contains("tvshows") || lower.contains("shows") || lower.contains("series") {
|
||||||
} else if lower == "left" || lower == "action(1)" || lower == "cursorleft" {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
|
||||||
let _ = nav_tx.send(RemoteNavAction::Left);
|
} else if lower.contains("movietitles") || lower.contains("videos") || lower.contains("movies") {
|
||||||
} else if lower == "right" || lower == "action(2)" || lower == "cursorright" {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
|
||||||
let _ = nav_tx.send(RemoteNavAction::Right);
|
} else if lower.contains("music") || lower.contains("songs") || lower.contains("artists") || lower.contains("albums") {
|
||||||
} else if lower == "select" || lower == "enter" || lower == "action(7)" || lower == "selectitem" {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
|
||||||
let _ = nav_tx.send(RemoteNavAction::Select);
|
} else if lower.contains("pictures") || lower.contains("photos") {
|
||||||
} else if lower == "back" || lower == "action(9)" || lower == "action(10)" || lower == "previousmenu" || lower == "parentdir" || lower == "close" {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(5));
|
||||||
let _ = nav_tx.send(RemoteNavAction::Back);
|
} else if lower.contains("livetv") || lower.contains("channels") || lower.contains("tv") {
|
||||||
} else if lower.contains("togglefullscreen") {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
|
||||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
} else if lower.contains("home") {
|
||||||
} else if lower.contains("action(osd)") || lower == "osd" || lower.contains("nextsubtitle") {
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
|
||||||
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
}
|
||||||
} else if lower.contains("volumeup") || lower == "action(88)" {
|
return;
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
}
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
let norm = normalize_action(action);
|
||||||
} else if lower.contains("volumedown") || lower == "action(89)" {
|
match norm.as_str() {
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
"up" | "cursorup" | "3" => {
|
||||||
let p = player.clone();
|
let _ = nav_tx.send(RemoteNavAction::Up);
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
}
|
||||||
} else if lower.contains("activatewindow(videos,movietitles") || lower.contains("activatewindow(videos)") {
|
"down" | "cursordown" | "4" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
|
let _ = nav_tx.send(RemoteNavAction::Down);
|
||||||
} else if lower.contains("activatewindow(videos,tvshowtitles") || lower.contains("activatewindow(tvshows") {
|
}
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
|
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => {
|
||||||
} else if lower.contains("activatewindow(music") {
|
let _ = nav_tx.send(RemoteNavAction::Left);
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
|
}
|
||||||
} else if lower.contains("activatewindow(pictures") || lower.contains("activatewindow(photos") {
|
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
|
let _ = nav_tx.send(RemoteNavAction::Right);
|
||||||
} else if lower.contains("activatewindow(home") {
|
}
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
|
"bigstepforward" => {
|
||||||
} else if lower.contains("playpause") || lower == "pause" || lower == "play" {
|
let p = player.clone();
|
||||||
let p = player.clone();
|
tokio::spawn(async move { p.seek(60.0).await; });
|
||||||
tokio::spawn(async move { p.play_pause().await; });
|
}
|
||||||
} else if lower.contains("stop") {
|
"bigstepback" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
let p = player.clone();
|
||||||
let p = player.clone();
|
tokio::spawn(async move { p.seek(-60.0).await; });
|
||||||
tokio::spawn(async move { p.stop().await; });
|
}
|
||||||
} else if lower.contains("mute") {
|
"select" | "enter" | "7" | "selectitem" => {
|
||||||
let p = player.clone();
|
let _ = nav_tx.send(RemoteNavAction::Select);
|
||||||
tokio::spawn(async move { p.toggle_mute().await; });
|
}
|
||||||
|
"back" | "parentdir" | "previousmenu" | "close" | "9" | "10" | "escape" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Back);
|
||||||
|
}
|
||||||
|
"title" | "topmenu" | "11" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||||
|
}
|
||||||
|
"menu" | "contextmenu" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
|
}
|
||||||
|
"togglefullscreen" | "fullscreen" | "12" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView);
|
||||||
|
}
|
||||||
|
"osd" | "showosd" | "114" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
|
}
|
||||||
|
"nextsubtitle" | "subtitles" | "showsubtitles" | "122" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||||
|
}
|
||||||
|
"audionextlanguage" | "nextaudio" | "nextaudiostream" | "cycleaudio" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::CycleAudio);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.cycle_audio().await; });
|
||||||
|
}
|
||||||
|
"play" | "pause" | "playpause" | "15" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::PlayPause);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.play_pause().await; });
|
||||||
|
}
|
||||||
|
"stop" | "14" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.stop().await; });
|
||||||
|
}
|
||||||
|
"volumeup" | "vol_up" | "88" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
|
}
|
||||||
|
"volumedown" | "vol_down" | "89" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
|
}
|
||||||
|
"mute" | "togglemute" | "volumemute" | "13" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleMute);
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
|
}
|
||||||
|
"info" | "showcodecinfo" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
||||||
|
}
|
||||||
|
"skipprevious" | "prev" => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.seek_time(0.0).await; });
|
||||||
|
}
|
||||||
|
"skipnext" | "next" => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.seek(60.0).await; });
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
tracing::info!("EventServer unhandled action: '{}' (norm: '{}')", action, other);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +506,7 @@ mod tests {
|
|||||||
async fn test_dispatch_button_navigation() {
|
async fn test_dispatch_button_navigation() {
|
||||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||||
let player = PlayerController::new(PlayerConfig::default());
|
let player = PlayerController::new(PlayerConfig::default());
|
||||||
|
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||||
|
|
||||||
let cases = vec![
|
let cases = vec![
|
||||||
("up", RemoteNavAction::Up),
|
("up", RemoteNavAction::Up),
|
||||||
@@ -419,7 +531,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (btn, expected) in cases {
|
for (btn, expected) in cases {
|
||||||
dispatch_button_or_code(btn, 0, &nav_tx, &player);
|
dispatch_button_or_code(btn, 0, &nav_sender, &player);
|
||||||
let action = nav_rx.recv().await.expect("Expected nav action");
|
let action = nav_rx.recv().await.expect("Expected nav action");
|
||||||
assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected);
|
assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected);
|
||||||
}
|
}
|
||||||
@@ -429,6 +541,7 @@ mod tests {
|
|||||||
async fn test_dispatch_button_raw_keycodes() {
|
async fn test_dispatch_button_raw_keycodes() {
|
||||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||||
let player = PlayerController::new(PlayerConfig::default());
|
let player = PlayerController::new(PlayerConfig::default());
|
||||||
|
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||||
|
|
||||||
let cases = vec![
|
let cases = vec![
|
||||||
(0xF010, RemoteNavAction::Up),
|
(0xF010, RemoteNavAction::Up),
|
||||||
@@ -452,7 +565,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (code, expected) in cases {
|
for (code, expected) in cases {
|
||||||
dispatch_button_or_code("unknown_btn", code, &nav_tx, &player);
|
dispatch_button_or_code("unknown_btn", code, &nav_sender, &player);
|
||||||
let action = nav_rx.recv().await.expect("Expected nav action for keycode");
|
let action = nav_rx.recv().await.expect("Expected nav action for keycode");
|
||||||
assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected);
|
assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected);
|
||||||
}
|
}
|
||||||
@@ -462,6 +575,7 @@ mod tests {
|
|||||||
async fn test_dispatch_action_navigation() {
|
async fn test_dispatch_action_navigation() {
|
||||||
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
let (nav_tx, mut nav_rx) = mpsc::unbounded_channel();
|
||||||
let player = PlayerController::new(PlayerConfig::default());
|
let player = PlayerController::new(PlayerConfig::default());
|
||||||
|
let nav_sender: RemoteNavSender = nav_tx.into();
|
||||||
|
|
||||||
let cases = vec![
|
let cases = vec![
|
||||||
("up", RemoteNavAction::Up),
|
("up", RemoteNavAction::Up),
|
||||||
@@ -486,10 +600,15 @@ mod tests {
|
|||||||
("previousmenu", RemoteNavAction::Back),
|
("previousmenu", RemoteNavAction::Back),
|
||||||
("parentdir", RemoteNavAction::Back),
|
("parentdir", RemoteNavAction::Back),
|
||||||
("close", RemoteNavAction::Back),
|
("close", RemoteNavAction::Back),
|
||||||
|
("ActivateWindow(Videos,TvShowTitles)", RemoteNavAction::ActivateSection(3)),
|
||||||
|
("ActivateWindow(Videos,MovieTitles)", RemoteNavAction::ActivateSection(2)),
|
||||||
|
("ActivateWindow(Music)", RemoteNavAction::ActivateSection(4)),
|
||||||
|
("ActivateWindow(Pictures)", RemoteNavAction::ActivateSection(5)),
|
||||||
|
("ActivateWindow(Home)", RemoteNavAction::ActivateSection(0)),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (act, expected) in cases {
|
for (act, expected) in cases {
|
||||||
dispatch_action(act, &nav_tx, &player);
|
dispatch_action(act, &nav_sender, &player);
|
||||||
let action = nav_rx.recv().await.expect("Expected nav action for action string");
|
let action = nav_rx.recv().await.expect("Expected nav action for action string");
|
||||||
assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected);
|
assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::{mpsc, RwLock, Semaphore};
|
||||||
|
use eframe::egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum ImageStatus {
|
||||||
|
Loaded(TextureHandle),
|
||||||
|
Loading,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_url(url: &str) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
url.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CacheInner {
|
||||||
|
textures: HashMap<String, (u64, TextureHandle)>,
|
||||||
|
in_flight: HashSet<String>,
|
||||||
|
failed: HashSet<String>,
|
||||||
|
access_counter: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AsyncImageCache {
|
||||||
|
inner: Mutex<CacheInner>,
|
||||||
|
tx: mpsc::UnboundedSender<(String, Option<ColorImage>)>,
|
||||||
|
rx: Mutex<mpsc::UnboundedReceiver<(String, Option<ColorImage>)>>,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
token: Arc<RwLock<Option<String>>>,
|
||||||
|
semaphore: Arc<Semaphore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncImageCache {
|
||||||
|
pub fn new(token: Arc<RwLock<Option<String>>>) -> Self {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(CacheInner {
|
||||||
|
textures: HashMap::new(),
|
||||||
|
in_flight: HashSet::new(),
|
||||||
|
failed: HashSet::new(),
|
||||||
|
access_counter: 0,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
rx: Mutex::new(rx),
|
||||||
|
http_client: reqwest::Client::new(),
|
||||||
|
token,
|
||||||
|
semaphore: Arc::new(Semaphore::new(10)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, ctx: &Context) {
|
||||||
|
let mut any_new = false;
|
||||||
|
if let Ok(mut rx_guard) = self.rx.try_lock() {
|
||||||
|
while let Ok((url, opt_img)) = rx_guard.try_recv() {
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
inner.in_flight.remove(&url);
|
||||||
|
match opt_img {
|
||||||
|
Some(color_image) => {
|
||||||
|
inner.access_counter += 1;
|
||||||
|
let counter = inner.access_counter;
|
||||||
|
let handle = ctx.load_texture(&url, color_image, TextureOptions::LINEAR);
|
||||||
|
inner.textures.insert(url, (counter, handle));
|
||||||
|
any_new = true;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
inner.failed.insert(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit cached textures to 500
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
if inner.textures.len() > 500 {
|
||||||
|
let mut entries: Vec<(String, u64)> = inner
|
||||||
|
.textures
|
||||||
|
.iter()
|
||||||
|
.map(|(k, (c, _))| (k.clone(), *c))
|
||||||
|
.collect();
|
||||||
|
entries.sort_by_key(|(_, c)| *c);
|
||||||
|
let to_remove = entries.len() - 400;
|
||||||
|
for (k, _) in entries.into_iter().take(to_remove) {
|
||||||
|
inner.textures.remove(&k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if any_new {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_or_load(&self, ctx: &Context, url: &str) -> ImageStatus {
|
||||||
|
if url.is_empty() {
|
||||||
|
return ImageStatus::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
inner.access_counter += 1;
|
||||||
|
let current_count = inner.access_counter;
|
||||||
|
if let Some((counter, handle)) = inner.textures.get_mut(url) {
|
||||||
|
*counter = current_count;
|
||||||
|
return ImageStatus::Loaded(handle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.failed.contains(url) {
|
||||||
|
return ImageStatus::Failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.in_flight.contains(url) {
|
||||||
|
return ImageStatus::Loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.in_flight.insert(url.to_string());
|
||||||
|
} else {
|
||||||
|
return ImageStatus::Loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url_clone = url.to_string();
|
||||||
|
let tx = self.tx.clone();
|
||||||
|
let client = self.http_client.clone();
|
||||||
|
let sem = self.semaphore.clone();
|
||||||
|
let ctx_clone = ctx.clone();
|
||||||
|
let token_opt = self.token.try_read().ok().and_then(|g| g.clone());
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _permit = sem.acquire().await;
|
||||||
|
let cache_dir = std::path::Path::new("/tmp/jellyfin-cache");
|
||||||
|
let file_name = format!("{:016x}.cache", hash_url(&url_clone));
|
||||||
|
let cache_file = cache_dir.join(file_name);
|
||||||
|
|
||||||
|
let mut bytes_opt = if let Ok(data) = std::fs::read(&cache_file) {
|
||||||
|
Some(data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if bytes_opt.is_none() {
|
||||||
|
let mut req = client.get(&url_clone);
|
||||||
|
if let Some(ref t) = token_opt {
|
||||||
|
req = req.header("X-Emby-Token", t);
|
||||||
|
req = req.header("Authorization", format!("MediaBrowser Token=\"{}\"", t));
|
||||||
|
}
|
||||||
|
if let Ok(resp) = req.send().await {
|
||||||
|
if resp.status().is_success() {
|
||||||
|
if let Ok(b) = resp.bytes().await {
|
||||||
|
let vec_bytes = b.to_vec();
|
||||||
|
let _ = std::fs::create_dir_all(cache_dir);
|
||||||
|
let _ = std::fs::write(&cache_file, &vec_bytes);
|
||||||
|
bytes_opt = Some(vec_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let color_img = if let Some(bytes) = bytes_opt {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if let Ok(dyn_img) = image::load_from_memory(&bytes) {
|
||||||
|
let rgba = dyn_img.to_rgba8();
|
||||||
|
let size = [rgba.width() as usize, rgba.height() as usize];
|
||||||
|
let pixels = rgba.into_raw();
|
||||||
|
Some(ColorImage::from_rgba_unmultiplied(size, &pixels))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send((url_clone, color_img));
|
||||||
|
ctx_clone.request_repaint();
|
||||||
|
});
|
||||||
|
|
||||||
|
ImageStatus::Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,16 @@ pub struct MediaItem {
|
|||||||
|
|
||||||
impl MediaItem {
|
impl MediaItem {
|
||||||
pub fn primary_image_id(&self) -> &str {
|
pub fn primary_image_id(&self) -> &str {
|
||||||
|
if let Some(ref p_id) = self.parent_primary_image_item_id {
|
||||||
|
if p_id.starts_with("http://") || p_id.starts_with("https://") {
|
||||||
|
return p_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref b_id) = self.parent_backdrop_item_id {
|
||||||
|
if b_id.starts_with("http://") || b_id.starts_with("https://") {
|
||||||
|
return b_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.has_primary_image {
|
if self.has_primary_image {
|
||||||
&self.id
|
&self.id
|
||||||
} else if let Some(ref p_id) = self.parent_primary_image_item_id {
|
} else if let Some(ref p_id) = self.parent_primary_image_item_id {
|
||||||
@@ -268,7 +278,25 @@ impl JellyfinClient {
|
|||||||
);
|
);
|
||||||
let mut items = fetch_endpoint(&self.client, &url, &headers).await;
|
let mut items = fetch_endpoint(&self.client, &url, &headers).await;
|
||||||
|
|
||||||
// 2. If empty, maybe this is an artist: try fetching albums via albumArtistIds
|
// 2. If empty, maybe this is a TV Show (Series): fetch seasons
|
||||||
|
if items.is_empty() {
|
||||||
|
let url_seasons = format!(
|
||||||
|
"{}/Shows/{}/Seasons?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||||
|
base_url, folder_id, user_id
|
||||||
|
);
|
||||||
|
items = fetch_endpoint(&self.client, &url_seasons, &headers).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If empty, maybe this is a Season: fetch episodes
|
||||||
|
if items.is_empty() {
|
||||||
|
let url_episodes = format!(
|
||||||
|
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||||
|
base_url, folder_id, user_id
|
||||||
|
);
|
||||||
|
items = fetch_endpoint(&self.client, &url_episodes, &headers).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If empty, maybe this is an artist: try fetching albums via albumArtistIds
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
let url_albums = format!(
|
let url_albums = format!(
|
||||||
"{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
"{}/Users/{}/Items?albumArtistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||||
@@ -277,7 +305,7 @@ impl JellyfinClient {
|
|||||||
items = fetch_endpoint(&self.client, &url_albums, &headers).await;
|
items = fetch_endpoint(&self.client, &url_albums, &headers).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If still empty, try artistIds for albums
|
// 5. If still empty, try artistIds for albums
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
let url_albums2 = format!(
|
let url_albums2 = format!(
|
||||||
"{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
"{}/Users/{}/Items?artistIds={}&includeItemTypes=MusicAlbum&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||||
@@ -286,7 +314,7 @@ impl JellyfinClient {
|
|||||||
items = fetch_endpoint(&self.client, &url_albums2, &headers).await;
|
items = fetch_endpoint(&self.client, &url_albums2, &headers).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly
|
// 6. If still empty (e.g. ABBA, *NSYNC who have no albums, only compilation tracks), fetch individual audio songs directly
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
let url_songs = format!(
|
let url_songs = format!(
|
||||||
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
"{}/Users/{}/Items?artistIds={}&includeItemTypes=Audio&recursive=true&sortBy=SortName&limit=500&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,SortName,Genres",
|
||||||
@@ -295,8 +323,8 @@ impl JellyfinClient {
|
|||||||
items = fetch_endpoint(&self.client, &url_songs, &headers).await;
|
items = fetch_endpoint(&self.client, &url_songs, &headers).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the folder items are Audio tracks, sort them by index_number
|
// If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number
|
||||||
if items.iter().any(|i| i.item_type == "Audio") {
|
if items.iter().any(|i| i.item_type == "Audio" || i.item_type == "Season" || i.item_type == "Episode") {
|
||||||
items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX));
|
items.sort_by_key(|i| i.index_number.unwrap_or(u32::MAX));
|
||||||
} else {
|
} else {
|
||||||
sort_media_items_alphabetically(&mut items);
|
sort_media_items_alphabetically(&mut items);
|
||||||
@@ -312,10 +340,6 @@ impl JellyfinClient {
|
|||||||
let guard = self.token.read().await;
|
let guard = self.token.read().await;
|
||||||
guard.clone().unwrap_or_default()
|
guard.clone().unwrap_or_default()
|
||||||
};
|
};
|
||||||
let user_id = {
|
|
||||||
let guard = self.user_id.read().await;
|
|
||||||
guard.clone().unwrap_or_default()
|
|
||||||
};
|
|
||||||
|
|
||||||
// 0. YouTube Video or direct stream URL
|
// 0. YouTube Video or direct stream URL
|
||||||
if item.item_type == "YouTubeVideo"
|
if item.item_type == "YouTubeVideo"
|
||||||
@@ -339,43 +363,13 @@ impl JellyfinClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Series -> resolve next unplayed episode
|
// 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
|
||||||
if item.item_type == "Series" {
|
if item.item_type == "Series"
|
||||||
if let Ok(headers) = self.get_authed_headers().await {
|
|| item.item_type == "Season"
|
||||||
let next_url = format!("{}/Shows/NextUp?seriesId={}&userId={}&fields=Genres", base_url, item.id, user_id);
|
|| item.item_type == "PhotoAlbum"
|
||||||
let episodes = fetch_endpoint(&self.client, &next_url, &headers).await;
|
|
||||||
if let Some(ep) = episodes.first() {
|
|
||||||
return Ok(PlayableTarget::Stream {
|
|
||||||
url: ep.stream_url(base_url, &token),
|
|
||||||
title: format!("{}: {}", item.name, ep.name),
|
|
||||||
media_type: "video".to_string(),
|
|
||||||
artist: None,
|
|
||||||
genre: ep.genre.clone().or_else(|| item.genre.clone()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Fallback: episode 1
|
|
||||||
let ep1_url = format!(
|
|
||||||
"{}/Users/{}/Items?parentId={}&includeItemTypes=Episode&recursive=true&sortBy=SortName&limit=1&fields=Genres",
|
|
||||||
base_url, user_id, item.id
|
|
||||||
);
|
|
||||||
let ep1 = fetch_endpoint(&self.client, &ep1_url, &headers).await;
|
|
||||||
if let Some(ep) = ep1.first() {
|
|
||||||
return Ok(PlayableTarget::Stream {
|
|
||||||
url: ep.stream_url(base_url, &token),
|
|
||||||
title: format!("{}: {}", item.name, ep.name),
|
|
||||||
media_type: "video".to_string(),
|
|
||||||
artist: None,
|
|
||||||
genre: ep.genre.clone().or_else(|| item.genre.clone()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI
|
|
||||||
if item.item_type == "PhotoAlbum"
|
|
||||||
|| item.item_type == "MusicArtist"
|
|| item.item_type == "MusicArtist"
|
||||||
|| item.item_type == "MusicAlbum"
|
|| item.item_type == "MusicAlbum"
|
||||||
|| (item.is_folder && item.item_type != "Series")
|
|| item.is_folder
|
||||||
{
|
{
|
||||||
return Ok(PlayableTarget::OpenFolder {
|
return Ok(PlayableTarget::OpenFolder {
|
||||||
folder_id: item.id.clone(),
|
folder_id: item.id.clone(),
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
mod eventserver;
|
mod eventserver;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -33,8 +35,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
// 2. Initialize Media Player (MPV Controller)
|
// 2. Initialize Media Player (MPV Controller)
|
||||||
let player = PlayerController::new(config.player.clone());
|
let player = PlayerController::new(config.player.clone());
|
||||||
|
|
||||||
// 3. Navigation channel (Yatse Remote -> UI)
|
// 3. Navigation channel (Yatse Remote -> UI) with immediate egui repaint trigger
|
||||||
let (nav_tx, nav_rx) = mpsc::unbounded_channel();
|
let (nav_tx_raw, nav_rx) = mpsc::unbounded_channel();
|
||||||
|
let egui_ctx_holder: std::sync::Arc<std::sync::Mutex<Option<eframe::egui::Context>>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let nav_tx = crate::yatse::RemoteNavSender::new(nav_tx_raw, egui_ctx_holder.clone());
|
||||||
|
|
||||||
// 4. Start Yatse Kodi JSON-RPC Server & EventServer
|
// 4. Start Yatse Kodi JSON-RPC Server & EventServer
|
||||||
if config.kodi_rpc.enabled {
|
if config.kodi_rpc.enabled {
|
||||||
@@ -103,40 +108,43 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
let app_client = client.clone();
|
let app_client = client.clone();
|
||||||
let app_player = player.clone();
|
let app_player = player.clone();
|
||||||
|
|
||||||
|
let egui_ctx_for_nav = egui_ctx_holder.clone();
|
||||||
// Run eframe app (blocks until window is closed)
|
// Run eframe app (blocks until window is closed)
|
||||||
tokio::task::block_in_place(|| {
|
tokio::task::block_in_place(|| {
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Jellyfin HTPC Kiosk",
|
"Jellyfin HTPC Kiosk",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(move |cc| {
|
Box::new(move |cc| {
|
||||||
|
*egui_ctx_for_nav.lock().unwrap() = Some(cc.egui_ctx.clone());
|
||||||
|
|
||||||
// Install egui image loaders (http, png, jpg)
|
// Install egui image loaders (http, png, jpg)
|
||||||
egui_extras::install_image_loaders(&cc.egui_ctx);
|
egui_extras::install_image_loaders(&cc.egui_ctx);
|
||||||
|
|
||||||
|
let mut window_id: Option<u64> = None;
|
||||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
if let Ok(handle) = cc.window_handle() {
|
if let Ok(handle) = cc.window_handle() {
|
||||||
match handle.as_raw() {
|
match handle.as_raw() {
|
||||||
RawWindowHandle::Xlib(h) => {
|
RawWindowHandle::Xlib(h) => {
|
||||||
let p = app_player.clone();
|
window_id = Some(h.window as u64);
|
||||||
let wid = h.window as u64;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
p.set_window_id(Some(wid)).await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
RawWindowHandle::Xcb(h) => {
|
RawWindowHandle::Xcb(h) => {
|
||||||
let p = app_player.clone();
|
window_id = Some(h.window.get() as u64);
|
||||||
let wid = h.window.get() as u64;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
p.set_window_id(Some(wid)).await;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(wid) = window_id {
|
||||||
|
let p = app_player.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
p.set_window_id(Some(wid)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
Ok(Box::new(KioskApp::new(
|
Ok(Box::new(KioskApp::new(
|
||||||
app_config,
|
app_config,
|
||||||
app_client,
|
app_client,
|
||||||
app_player,
|
app_player,
|
||||||
nav_rx,
|
nav_rx,
|
||||||
|
window_id,
|
||||||
)))
|
)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ pub struct PlayerController {
|
|||||||
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>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
@@ -67,6 +68,7 @@ impl PlayerController {
|
|||||||
status_tx,
|
status_tx,
|
||||||
status_rx,
|
status_rx,
|
||||||
window_id: Mutex::new(None),
|
window_id: Mutex::new(None),
|
||||||
|
ipc_tx: Mutex::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +141,13 @@ impl PlayerController {
|
|||||||
cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket));
|
cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket));
|
||||||
cmd.arg(format!("--hwdec={}", self.config.hwdec));
|
cmd.arg(format!("--hwdec={}", self.config.hwdec));
|
||||||
cmd.arg(format!("--vo={}", self.config.vo));
|
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
|
// Configure yt-dlp for YouTube streams
|
||||||
let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
|
let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() {
|
||||||
@@ -189,12 +198,19 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Start background IPC monitoring task
|
||||||
let sock_file = self.config.ipc_socket.clone();
|
let sock_file = self.config.ipc_socket.clone();
|
||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let status_tx = self.status_tx.clone();
|
let status_tx = self.status_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx).await;
|
let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx, ipc_rx).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -212,7 +228,7 @@ impl PlayerController {
|
|||||||
PlaybackState::Stopped => PlaybackState::Stopped,
|
PlaybackState::Stopped => PlaybackState::Stopped,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = self.send_ipc_cmd(&["cycle", "pause"]).await;
|
let _ = self.send_ipc_cmd(&["osd-msg", "cycle", "pause"]).await;
|
||||||
{
|
{
|
||||||
let mut st = self.status.lock().await;
|
let mut st = self.status.lock().await;
|
||||||
st.state = new_state;
|
st.state = new_state;
|
||||||
@@ -222,6 +238,10 @@ impl PlayerController {
|
|||||||
|
|
||||||
pub async fn stop(&self) {
|
pub async fn stop(&self) {
|
||||||
let _ = self.send_ipc_cmd(&["stop"]).await;
|
let _ = self.send_ipc_cmd(&["stop"]).await;
|
||||||
|
{
|
||||||
|
let mut guard = self.ipc_tx.lock().await;
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
let mut proc_guard = self.child_process.lock().await;
|
let mut proc_guard = self.child_process.lock().await;
|
||||||
if let Some(mut child) = proc_guard.take() {
|
if let Some(mut child) = proc_guard.take() {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
@@ -241,7 +261,17 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn seek(&self, seconds: f64) {
|
pub async fn seek(&self, seconds: f64) {
|
||||||
let _ = self.send_ipc_cmd(&["seek", &seconds.to_string(), "relative"]).await;
|
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &seconds.to_string(), "relative"]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seek_percent(&self, percent: f64) {
|
||||||
|
let p = percent.clamp(0.0, 100.0);
|
||||||
|
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &p.to_string(), "absolute-percent"]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seek_time(&self, seconds: f64) {
|
||||||
|
let s = seconds.max(0.0);
|
||||||
|
let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &s.to_string(), "absolute"]).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn toggle_mute(&self) {
|
pub async fn toggle_mute(&self) {
|
||||||
@@ -272,7 +302,7 @@ 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(&["set", "volume", &vol.to_string()]).await;
|
let _ = self.send_ipc_cmd(&["osd-msg-bar", "set", "volume", &vol.to_string()]).await;
|
||||||
self.notify_status().await;
|
self.notify_status().await;
|
||||||
tracing::info!("Player volume set to {}", vol);
|
tracing::info!("Player volume set to {}", vol);
|
||||||
}
|
}
|
||||||
@@ -283,7 +313,7 @@ impl PlayerController {
|
|||||||
st.volume = (st.volume + 5.0).min(100.0);
|
st.volume = (st.volume + 5.0).min(100.0);
|
||||||
st.volume
|
st.volume
|
||||||
};
|
};
|
||||||
let _ = self.send_ipc_cmd(&["add", "volume", "5"]).await;
|
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);
|
tracing::info!("Player volume increased to {}", new_vol);
|
||||||
}
|
}
|
||||||
@@ -294,28 +324,49 @@ impl PlayerController {
|
|||||||
st.volume = (st.volume - 5.0).max(0.0);
|
st.volume = (st.volume - 5.0).max(0.0);
|
||||||
st.volume
|
st.volume
|
||||||
};
|
};
|
||||||
let _ = self.send_ipc_cmd(&["add", "volume", "-5"]).await;
|
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);
|
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;
|
let _ = self.send_ipc_cmd(&["cycle", "sub"]).await;
|
||||||
|
let _ = self.send_ipc_cmd(&[
|
||||||
|
"expand-properties",
|
||||||
|
"show-text",
|
||||||
|
"💬 Ondertitels: ${current-tracks/sub/title:${current-tracks/sub/lang:${sub:Geen beschikbaar}}}",
|
||||||
|
"2500",
|
||||||
|
]).await;
|
||||||
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;
|
let _ = self.send_ipc_cmd(&["cycle", "audio"]).await;
|
||||||
|
let _ = self.send_ipc_cmd(&[
|
||||||
|
"expand-properties",
|
||||||
|
"show-text",
|
||||||
|
"🎬 Audio: ${current-tracks/audio/title:${current-tracks/audio/lang:${audio:Standaard}}}",
|
||||||
|
"2500",
|
||||||
|
]).await;
|
||||||
tracing::info!("Cycled audio track");
|
tracing::info!("Cycled audio track");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_video_visible(&self, visible: bool) {
|
||||||
|
let arg = if visible { "auto" } else { "no" };
|
||||||
|
let _ = self.send_ipc_cmd(&["set", "vid", arg]).await;
|
||||||
|
tracing::info!("Set MPV video visibility to {}", visible);
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut stream = UnixStream::connect(&self.config.ipc_socket).await?;
|
let guard = self.ipc_tx.lock().await;
|
||||||
let cmd_json = serde_json::json!({
|
if let Some(ref tx) = *guard {
|
||||||
"command": args
|
let cmd_json = serde_json::json!({
|
||||||
});
|
"command": args
|
||||||
stream.write_all(format!("{}\n", cmd_json).as_bytes()).await?;
|
});
|
||||||
Ok(())
|
let _ = tx.send(cmd_json);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err("MPV IPC not connected".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +374,7 @@ async fn monitor_mpv_ipc(
|
|||||||
sock_path: String,
|
sock_path: String,
|
||||||
status: Arc<Mutex<PlayerStatus>>,
|
status: Arc<Mutex<PlayerStatus>>,
|
||||||
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
status_tx: tokio::sync::watch::Sender<PlayerStatus>,
|
||||||
|
mut ipc_rx: tokio::sync::mpsc::UnboundedReceiver<serde_json::Value>,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// Wait for socket to become available
|
// Wait for socket to become available
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
@@ -356,7 +408,23 @@ async fn monitor_mpv_ipc(
|
|||||||
let _ = writer.write_all(format!("{}\n", c).as_bytes()).await;
|
let _ = writer.write_all(format!("{}\n", c).as_bytes()).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Ok(Some(line)) = lines.next_line().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 Ok(v) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||||
if let Some(event) = v.get("event").and_then(|e| e.as_str()) {
|
if let Some(event) = v.get("event").and_then(|e| e.as_str()) {
|
||||||
if event == "property-change" {
|
if event == "property-change" {
|
||||||
@@ -487,6 +555,11 @@ async fn monitor_mpv_ipc(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
-42
@@ -21,17 +21,51 @@ pub enum RemoteNavAction {
|
|||||||
Back,
|
Back,
|
||||||
ToggleOSD,
|
ToggleOSD,
|
||||||
CycleSubtitle,
|
CycleSubtitle,
|
||||||
|
CycleAudio,
|
||||||
|
ToggleMute,
|
||||||
TogglePlaybackView,
|
TogglePlaybackView,
|
||||||
ActivateSection(usize),
|
ActivateSection(usize),
|
||||||
VolumeUp,
|
VolumeUp,
|
||||||
VolumeDown,
|
VolumeDown,
|
||||||
|
PlayPause,
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RemoteNavSender {
|
||||||
|
tx: UnboundedSender<RemoteNavAction>,
|
||||||
|
ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteNavSender {
|
||||||
|
pub fn new(tx: UnboundedSender<RemoteNavAction>, ctx: Arc<std::sync::Mutex<Option<eframe::egui::Context>>>) -> Self {
|
||||||
|
Self { tx, ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError<RemoteNavAction>> {
|
||||||
|
let res = self.tx.send(action);
|
||||||
|
if let Ok(guard) = self.ctx.lock() {
|
||||||
|
if let Some(ref c) = *guard {
|
||||||
|
c.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UnboundedSender<RemoteNavAction>> for RemoteNavSender {
|
||||||
|
fn from(tx: UnboundedSender<RemoteNavAction>) -> Self {
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
ctx: Arc::new(std::sync::Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct YatseServerState {
|
pub struct YatseServerState {
|
||||||
pub player: Arc<PlayerController>,
|
pub player: Arc<PlayerController>,
|
||||||
pub nav_tx: UnboundedSender<RemoteNavAction>,
|
pub nav_tx: RemoteNavSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -142,17 +176,26 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
}
|
}
|
||||||
"Application.SetVolume" => {
|
"Application.SetVolume" => {
|
||||||
let vol_param = req.params.as_ref()
|
let vol_param = req.params.as_ref()
|
||||||
.and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first())))
|
.and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first())));
|
||||||
.and_then(|v| v.as_f64());
|
|
||||||
|
|
||||||
let new_vol = match vol_param {
|
let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) {
|
||||||
Some(v) => {
|
if v_str == "increment" {
|
||||||
state.player.set_volume(v).await;
|
state.player.volume_up().await;
|
||||||
v.clamp(0.0, 100.0) as i64
|
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
||||||
}
|
state.player.status.lock().await.volume as i64
|
||||||
None => {
|
} else if v_str == "decrement" {
|
||||||
|
state.player.volume_down().await;
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
|
||||||
|
state.player.status.lock().await.volume as i64
|
||||||
|
} else {
|
||||||
state.player.status.lock().await.volume as i64
|
state.player.status.lock().await.volume as i64
|
||||||
}
|
}
|
||||||
|
} else if let Some(v) = vol_param.and_then(|v| v.as_f64()) {
|
||||||
|
state.player.set_volume(v).await;
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
||||||
|
v.clamp(0.0, 100.0) as i64
|
||||||
|
} else {
|
||||||
|
state.player.status.lock().await.volume as i64
|
||||||
};
|
};
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -163,9 +206,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
"Player.GetActivePlayers" => {
|
"Player.GetActivePlayers" => {
|
||||||
let st = state.player.status.lock().await;
|
let st = state.player.status.lock().await;
|
||||||
if st.state != PlaybackState::Stopped {
|
if st.state != PlaybackState::Stopped {
|
||||||
|
let pid = if st.media_type == "audio" { 0 } else { 1 };
|
||||||
|
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": [{"playerid": 1, "playertype": "internal", "type": "video"}],
|
"result": [{"playerid": pid, "playertype": "internal", "type": ptype}],
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -181,28 +226,55 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
let time = seconds_to_kodi_time(st.time_pos);
|
let time = seconds_to_kodi_time(st.time_pos);
|
||||||
let totaltime = seconds_to_kodi_time(st.duration);
|
let totaltime = seconds_to_kodi_time(st.duration);
|
||||||
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||||
|
let ptype = if st.media_type == "audio" { "audio" } else { "video" };
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {
|
"result": {
|
||||||
"canseek": true,
|
"canseek": true,
|
||||||
|
"canchangespeed": true,
|
||||||
|
"canmove": false,
|
||||||
|
"canzoom": false,
|
||||||
|
"canrotate": false,
|
||||||
"speed": speed,
|
"speed": speed,
|
||||||
"time": time,
|
"time": time,
|
||||||
"totaltime": totaltime,
|
"totaltime": totaltime,
|
||||||
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 }
|
"percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 },
|
||||||
|
"type": ptype,
|
||||||
|
"live": false,
|
||||||
|
"subtitleenabled": true,
|
||||||
|
"subtitles": [
|
||||||
|
{"index": 0, "name": "Ondertitel 1", "language": ""},
|
||||||
|
{"index": 1, "name": "Ondertitel 2", "language": ""}
|
||||||
|
],
|
||||||
|
"currentsubtitle": {"index": 0, "name": "Ondertitel", "language": ""},
|
||||||
|
"audiostreams": [
|
||||||
|
{"index": 0, "name": "Audiostroom 1", "language": ""},
|
||||||
|
{"index": 1, "name": "Audiostroom 2", "language": ""}
|
||||||
|
],
|
||||||
|
"currentaudiostream": {"index": 0, "name": "Audiostroom", "language": ""}
|
||||||
},
|
},
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
"Player.GetItem" => {
|
"Player.GetItem" => {
|
||||||
let st = state.player.status.lock().await;
|
let st = state.player.status.lock().await;
|
||||||
|
let title = if !st.stream_title.is_empty() {
|
||||||
|
&st.stream_title
|
||||||
|
} else if !st.title.is_empty() {
|
||||||
|
&st.title
|
||||||
|
} else {
|
||||||
|
"Media"
|
||||||
|
};
|
||||||
|
let itype = if st.media_type == "audio" { "song" } else { "movie" };
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {
|
"result": {
|
||||||
"item": {
|
"item": {
|
||||||
"label": st.title,
|
"label": title,
|
||||||
"title": st.title,
|
"title": title,
|
||||||
"type": "movie"
|
"artist": if !st.artist.is_empty() { vec![st.artist.clone()] } else { Vec::new() },
|
||||||
|
"type": itype
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"id": id
|
"id": id
|
||||||
@@ -220,6 +292,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
}
|
}
|
||||||
"Player.Stop" => {
|
"Player.Stop" => {
|
||||||
state.player.stop().await;
|
state.player.stop().await;
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": "OK",
|
"result": "OK",
|
||||||
@@ -227,17 +300,87 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
"Player.Seek" => {
|
"Player.Seek" => {
|
||||||
if let Some(params) = req.params {
|
let mut result_pct = 0.0;
|
||||||
if let Some(sec) = params.get("value").and_then(|v| v.as_f64()) {
|
if let Some(ref params) = req.params {
|
||||||
state.player.seek(sec).await;
|
let val = params.get("value").or_else(|| params.as_array().and_then(|a| a.get(1)));
|
||||||
|
if let Some(v) = val {
|
||||||
|
if let Some(pct) = v.get("percentage").and_then(|p| p.as_f64()) {
|
||||||
|
state.player.seek_percent(pct).await;
|
||||||
|
result_pct = pct;
|
||||||
|
} else if let Some(time_obj) = v.get("time") {
|
||||||
|
let h = time_obj.get("hours").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||||
|
let m = time_obj.get("minutes").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||||
|
let s = time_obj.get("seconds").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||||
|
let total_secs = h * 3600.0 + m * 60.0 + s;
|
||||||
|
state.player.seek_time(total_secs).await;
|
||||||
|
} else if let Some(step) = v.as_str() {
|
||||||
|
match step.to_lowercase().as_str() {
|
||||||
|
"smallforward" => { state.player.seek(10.0).await; }
|
||||||
|
"smallbackward" => { state.player.seek(-10.0).await; }
|
||||||
|
"bigforward" => { state.player.seek(60.0).await; }
|
||||||
|
"bigbackward" => { state.player.seek(-60.0).await; }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if let Some(n) = v.as_f64() {
|
||||||
|
if n >= 0.0 && n <= 100.0 {
|
||||||
|
state.player.seek_percent(n).await;
|
||||||
|
result_pct = n;
|
||||||
|
} else {
|
||||||
|
state.player.seek(n).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let st = state.player.status.lock().await;
|
||||||
|
let time = seconds_to_kodi_time(st.time_pos);
|
||||||
|
let totaltime = seconds_to_kodi_time(st.duration);
|
||||||
|
let pct = if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { result_pct };
|
||||||
json!({
|
json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"result": {"percentage": 50.0},
|
"result": {
|
||||||
|
"percentage": pct,
|
||||||
|
"time": time,
|
||||||
|
"totaltime": totaltime
|
||||||
|
},
|
||||||
"id": id
|
"id": id
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
"Player.SetSubtitle" => {
|
||||||
|
state.player.cycle_subtitle().await;
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle);
|
||||||
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
|
}
|
||||||
|
"Player.SetAudioStream" => {
|
||||||
|
state.player.cycle_audio().await;
|
||||||
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
|
}
|
||||||
|
"Player.SetSpeed" => {
|
||||||
|
if let Some(ref params) = req.params {
|
||||||
|
let speed_val = params.get("speed").or_else(|| params.as_array().and_then(|a| a.get(1)));
|
||||||
|
if let Some(s) = speed_val.and_then(|v| v.as_i64()) {
|
||||||
|
if s == 0 {
|
||||||
|
let st = state.player.status.lock().await;
|
||||||
|
if st.state == PlaybackState::Playing {
|
||||||
|
drop(st);
|
||||||
|
state.player.play_pause().await;
|
||||||
|
}
|
||||||
|
} else if s == 1 {
|
||||||
|
let st = state.player.status.lock().await;
|
||||||
|
if st.state == PlaybackState::Paused {
|
||||||
|
drop(st);
|
||||||
|
state.player.play_pause().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(s_str) = speed_val.and_then(|v| v.as_str()) {
|
||||||
|
if s_str == "increment" || s_str == "decrement" {
|
||||||
|
state.player.play_pause().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let st = state.player.status.lock().await;
|
||||||
|
let speed = if st.state == PlaybackState::Playing { 1 } else { 0 };
|
||||||
|
json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id})
|
||||||
|
}
|
||||||
"Player.Open" => {
|
"Player.Open" => {
|
||||||
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") {
|
||||||
@@ -285,50 +428,75 @@ 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})
|
||||||
}
|
}
|
||||||
"Input.ExecuteAction" => {
|
"Input.ExecuteAction" => {
|
||||||
let action = req.params.as_ref()
|
let raw_action = req.params.as_ref()
|
||||||
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
|
.and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first())))
|
||||||
.and_then(|a| a.as_str())
|
.and_then(|a| a.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("");
|
||||||
.to_lowercase();
|
let action = if raw_action.to_lowercase().starts_with("action(") && raw_action.ends_with(')') {
|
||||||
|
raw_action[7..raw_action.len() - 1].trim().to_lowercase()
|
||||||
|
} else {
|
||||||
|
raw_action.to_lowercase()
|
||||||
|
};
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
"up" | "cursorup" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
|
"up" | "cursorup" | "3" => { let _ = state.nav_tx.send(RemoteNavAction::Up); }
|
||||||
"down" | "cursordown" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
|
"down" | "cursordown" | "4" => { let _ = state.nav_tx.send(RemoteNavAction::Down); }
|
||||||
"left" | "cursorleft" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
|
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => { let _ = state.nav_tx.send(RemoteNavAction::Left); }
|
||||||
"right" | "cursorright" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
|
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => { let _ = state.nav_tx.send(RemoteNavAction::Right); }
|
||||||
"select" | "enter" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
|
"bigstepforward" => {
|
||||||
"back" | "previousmenu" | "parentdir" | "close" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
|
let p = state.player.clone();
|
||||||
"togglefullscreen" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
|
tokio::spawn(async move { p.seek(60.0).await; });
|
||||||
"osd" | "nextsubtitle" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
|
}
|
||||||
"title" | "menu" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
|
"bigstepback" => {
|
||||||
"volumeup" => {
|
let p = state.player.clone();
|
||||||
|
tokio::spawn(async move { p.seek(-60.0).await; });
|
||||||
|
}
|
||||||
|
"select" | "enter" | "7" => { let _ = state.nav_tx.send(RemoteNavAction::Select); }
|
||||||
|
"back" | "previousmenu" | "parentdir" | "close" | "9" | "10" | "escape" => { let _ = state.nav_tx.send(RemoteNavAction::Back); }
|
||||||
|
"togglefullscreen" | "12" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); }
|
||||||
|
"osd" | "nextsubtitle" | "114" | "122" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); }
|
||||||
|
"audionextlanguage" | "nextaudio" => {
|
||||||
|
let p = state.player.clone();
|
||||||
|
tokio::spawn(async move { p.cycle_audio().await; });
|
||||||
|
}
|
||||||
|
"title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); }
|
||||||
|
"volumeup" | "88" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeUp);
|
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" => {
|
"volumedown" | "89" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::VolumeDown);
|
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" => { state.player.play_pause().await; }
|
"playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; }
|
||||||
"stop" => {
|
"stop" | "14" => {
|
||||||
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
let _ = state.nav_tx.send(RemoteNavAction::Stop);
|
||||||
state.player.stop().await;
|
state.player.stop().await;
|
||||||
}
|
}
|
||||||
|
"mute" | "togglemute" | "13" => {
|
||||||
|
let p = state.player.clone();
|
||||||
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
}
|
}
|
||||||
"GUI.ActivateWindow" => {
|
"GUI.ActivateWindow" => {
|
||||||
if let Some(params) = req.params {
|
if let Some(params) = req.params {
|
||||||
let window = params.get("window").and_then(|w| w.as_str()).unwrap_or("").to_lowercase();
|
let p_str = params.to_string().to_lowercase();
|
||||||
match window.as_str() {
|
if p_str.contains("tvshow") || p_str.contains("series") {
|
||||||
"videos" | "video" | "movies" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); }
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3));
|
||||||
"tvshows" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); }
|
} else if p_str.contains("movietitles") || p_str.contains("movie") || p_str.contains("video") {
|
||||||
"music" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); }
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2));
|
||||||
"pictures" | "photos" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); }
|
} else if p_str.contains("music") || p_str.contains("song") || p_str.contains("artist") || p_str.contains("album") {
|
||||||
"home" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0)); }
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4));
|
||||||
_ => {}
|
} else if p_str.contains("picture") || p_str.contains("photo") {
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(5));
|
||||||
|
} else if p_str.contains("livetv") || p_str.contains("channel") || p_str.contains("tv") {
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1));
|
||||||
|
} else if p_str.contains("home") {
|
||||||
|
let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
json!({"jsonrpc": "2.0", "result": "OK", "id": id})
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ impl YouTubeClient {
|
|||||||
return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string());
|
return Err("YouTube Client ID is niet ingesteld in config.yaml".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracing::info!("Starting YouTube OAuth Device Code flow for client_id: {}...", self.config.client_id);
|
||||||
|
|
||||||
let params = [
|
let params = [
|
||||||
("client_id", self.config.client_id.as_str()),
|
("client_id", self.config.client_id.as_str()),
|
||||||
("scope", "https://www.googleapis.com/auth/youtube.readonly"),
|
("scope", "https://www.googleapis.com/auth/youtube.readonly"),
|
||||||
@@ -213,18 +215,34 @@ impl YouTubeClient {
|
|||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Netwerkfout bij starten authenticatie: {}", e))?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Network error starting YouTube device auth: {}", e);
|
||||||
|
format!("Netwerkfout bij starten authenticatie: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
if !res.status().is_success() {
|
||||||
let status = res.status();
|
let status = res.status();
|
||||||
let text = res.text().await.unwrap_or_default();
|
let text = res.text().await.unwrap_or_default();
|
||||||
|
tracing::error!("Google OAuth device/code error (status {}): {}", status, text);
|
||||||
|
if text.contains("Invalid client type") {
|
||||||
|
return Err("Ongeldig OAuth Client ID type. Kies in Google Cloud Console bij het aanmaken van het Client ID het type: 'Tv's en apparaten met beperkte invoer' (TVs and Limited Input devices).".to_string());
|
||||||
|
}
|
||||||
return Err(format!("Google API fout ({}): {}", status, text));
|
return Err(format!("Google API fout ({}): {}", status, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp: DeviceCodeResponse = res
|
let resp: DeviceCodeResponse = res
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Ongeldig antwoord van Google: {}", e))?;
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to parse Google response: {}", e);
|
||||||
|
format!("Ongeldig antwoord van Google: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"YouTube Device Code generated: user_code='{}', url='{}'",
|
||||||
|
resp.user_code,
|
||||||
|
resp.verification_url
|
||||||
|
);
|
||||||
|
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"access_token": "ya29.a0AehfTOkVjtpy5kB4TQRuD7E0MXJAKSipRjAkysg0HnjvBvZlnb1y2WQ6UCSPxOLPliLxdri6A9AB_MlgMPHp1AXnsKHXL3dA_tkVYp3yuAhud4nUCniG0WDpgUpJ_paOAGYf5vtiJqDprd7W_8612JfnVwKW2ymR1ptNJpJ-aZivVvMsebsJcjDruq05haZGvmQtTkLTaCgYKAYESARESFQHGX2MiCxxRMG9_Lvhm7KGNtUmMIw0207",
|
||||||
|
"refresh_token": "1//09y6yuAbCKpGPCgYIARAAGAkSNwF-L9Ira4vVAsGF3K-oU7bEma5ZfUMy87MfInMEA_RvnDhxi_koUBGHGuLLAR5tbB9UZ6jcQOk",
|
||||||
|
"expires_at": 1793817948,
|
||||||
|
"token_type": "Bearer"
|
||||||
|
}
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-bart-workstation.lan}"
|
||||||
|
REMOTE_DIR="${REMOTE_DIR:-~/builds/jellyfin-client}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CLIENT_DIR="${SCRIPT_DIR}"
|
||||||
|
KIOSK_DIR="${SCRIPT_DIR}/jellyfin-kiosk-rs"
|
||||||
|
|
||||||
|
COMMAND="${1:-release}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "${COMMAND}" in
|
||||||
|
check)
|
||||||
|
CARGO_CMD="cargo check $*"
|
||||||
|
FETCH_BIN=false
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
CARGO_CMD="cargo build $*"
|
||||||
|
FETCH_BIN=false
|
||||||
|
;;
|
||||||
|
release)
|
||||||
|
CARGO_CMD="cargo build --release $*"
|
||||||
|
FETCH_BIN=true
|
||||||
|
;;
|
||||||
|
test)
|
||||||
|
CARGO_CMD="cargo test $*"
|
||||||
|
FETCH_BIN=false
|
||||||
|
;;
|
||||||
|
cargo)
|
||||||
|
CARGO_CMD="cargo $*"
|
||||||
|
FETCH_BIN=false
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
CARGO_CMD="cargo ${COMMAND} $*"
|
||||||
|
FETCH_BIN=false
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> 📡 Syncing source code to ${REMOTE_HOST}:${REMOTE_DIR}..."
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude 'target' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '.venv' \
|
||||||
|
--exclude '__pycache__' \
|
||||||
|
--exclude '.pytest_cache' \
|
||||||
|
"${CLIENT_DIR}/" "${REMOTE_HOST}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
|
echo "==> 🚀 Running on ${REMOTE_HOST}: ${CARGO_CMD}..."
|
||||||
|
ssh -t "${REMOTE_HOST}" "cd ${REMOTE_DIR}/jellyfin-kiosk-rs && ${CARGO_CMD}"
|
||||||
|
|
||||||
|
if [ "${FETCH_BIN}" = true ]; then
|
||||||
|
echo "==> 📥 Downloading compiled release binary to laptop..."
|
||||||
|
mkdir -p "${KIOSK_DIR}/target/release"
|
||||||
|
rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/target/release/"
|
||||||
|
cp "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk"
|
||||||
|
echo "==> ✅ Klaar! Binary beschikbaar in:"
|
||||||
|
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
|
||||||
|
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user