Compare commits
3
Commits
99cbc00357
...
3a407f90f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a407f90f7 | ||
|
|
4f2cdbd817 | ||
|
|
d77cdfedab |
Generated
+9
-1
@@ -1460,12 +1460,14 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellyfin-kiosk-rs"
|
name = "jellyfin-kiosk-rs"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -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.
+1340
-425
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,38 +270,46 @@ 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);
|
||||||
|
}
|
||||||
"play" | "pause" | "playpause" => {
|
"play" | "pause" | "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" => {
|
"mute" | "volumemute" | "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,63 +319,147 @@ 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);
|
||||||
|
}
|
||||||
|
0xF02B => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.toggle_mute().await; });
|
||||||
|
}
|
||||||
|
0xF02C => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
|
}
|
||||||
|
0xF02D => {
|
||||||
|
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::Left);
|
|
||||||
} else if lower == "right" || lower == "action(2)" || lower == "cursorright" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::Right);
|
|
||||||
} else if lower == "select" || lower == "enter" || lower == "action(7)" || lower == "selectitem" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::Select);
|
|
||||||
} else if lower == "back" || lower == "action(9)" || lower == "action(10)" || lower == "previousmenu" || lower == "parentdir" || lower == "close" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::Back);
|
|
||||||
} else if lower.contains("togglefullscreen") {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::ToggleOSD);
|
|
||||||
} else if lower.contains("action(osd)") || lower == "osd" || lower.contains("nextsubtitle") {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::CycleSubtitle);
|
|
||||||
} else if lower.contains("volumeup") || lower == "action(88)" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeUp);
|
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.volume_up().await; });
|
|
||||||
} else if lower.contains("volumedown") || lower == "action(89)" {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::VolumeDown);
|
|
||||||
let p = player.clone();
|
|
||||||
tokio::spawn(async move { p.volume_down().await; });
|
|
||||||
} else if lower.contains("activatewindow(videos,movietitles") || lower.contains("activatewindow(videos)") {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
|
|
||||||
} else if lower.contains("activatewindow(videos,tvshowtitles") || lower.contains("activatewindow(tvshows") {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
|
|
||||||
} else if lower.contains("activatewindow(music") {
|
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(3));
|
||||||
} else if lower.contains("activatewindow(pictures") || lower.contains("activatewindow(photos") {
|
} else if lower.contains("movietitles") || lower.contains("videos") || lower.contains("movies") {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(2));
|
||||||
|
} else if lower.contains("music") || lower.contains("songs") || lower.contains("artists") || lower.contains("albums") {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(4));
|
||||||
} else if lower.contains("activatewindow(home") {
|
} else if lower.contains("pictures") || lower.contains("photos") {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(5));
|
||||||
|
} else if lower.contains("livetv") || lower.contains("channels") || lower.contains("tv") {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(1));
|
||||||
|
} else if lower.contains("home") {
|
||||||
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
|
let _ = nav_tx.send(RemoteNavAction::ActivateSection(0));
|
||||||
} else if lower.contains("playpause") || lower == "pause" || lower == "play" {
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let norm = normalize_action(action);
|
||||||
|
match norm.as_str() {
|
||||||
|
"up" | "cursorup" | "3" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Up);
|
||||||
|
}
|
||||||
|
"down" | "cursordown" | "4" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Down);
|
||||||
|
}
|
||||||
|
"left" | "cursorleft" | "1" | "stepback" | "smallstepback" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Left);
|
||||||
|
}
|
||||||
|
"right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Right);
|
||||||
|
}
|
||||||
|
"bigstepforward" => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.seek(60.0).await; });
|
||||||
|
}
|
||||||
|
"bigstepback" => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.seek(-60.0).await; });
|
||||||
|
}
|
||||||
|
"select" | "enter" | "7" | "selectitem" => {
|
||||||
|
let _ = nav_tx.send(RemoteNavAction::Select);
|
||||||
|
}
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
"play" | "pause" | "playpause" | "15" => {
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.play_pause().await; });
|
tokio::spawn(async move { p.play_pause().await; });
|
||||||
} else if lower.contains("stop") {
|
}
|
||||||
|
"stop" | "14" => {
|
||||||
let _ = nav_tx.send(RemoteNavAction::Stop);
|
let _ = nav_tx.send(RemoteNavAction::Stop);
|
||||||
|
}
|
||||||
|
"volumeup" | "vol_up" | "88" => {
|
||||||
let p = player.clone();
|
let p = player.clone();
|
||||||
tokio::spawn(async move { p.stop().await; });
|
tokio::spawn(async move { p.volume_up().await; });
|
||||||
} else if lower.contains("mute") {
|
}
|
||||||
|
"volumedown" | "vol_down" | "89" => {
|
||||||
|
let p = player.clone();
|
||||||
|
tokio::spawn(async move { p.volume_down().await; });
|
||||||
|
}
|
||||||
|
"mute" | "togglemute" | "volumemute" | "13" => {
|
||||||
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);
|
||||||
|
}
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -395,6 +486,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 +511,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 +521,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 +545,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 +555,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 +580,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ pub struct MediaItem {
|
|||||||
pub artist_name: Option<String>,
|
pub artist_name: Option<String>,
|
||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
pub index_number: Option<u32>,
|
pub index_number: Option<u32>,
|
||||||
|
pub parent_index_number: Option<u32>,
|
||||||
|
pub season_name: Option<String>,
|
||||||
pub year: Option<u32>,
|
pub year: Option<u32>,
|
||||||
pub overview: Option<String>,
|
pub overview: Option<String>,
|
||||||
pub rating: Option<f32>,
|
pub rating: Option<f32>,
|
||||||
@@ -50,6 +52,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 {
|
||||||
@@ -187,6 +199,7 @@ pub struct JellyfinClient {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
pub token: Arc<RwLock<Option<String>>>,
|
pub token: Arc<RwLock<Option<String>>>,
|
||||||
pub user_id: Arc<RwLock<Option<String>>>,
|
pub user_id: Arc<RwLock<Option<String>>>,
|
||||||
|
pub library: Arc<RwLock<MediaLibrary>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JellyfinClient {
|
impl JellyfinClient {
|
||||||
@@ -196,6 +209,7 @@ impl JellyfinClient {
|
|||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
token: Arc::new(RwLock::new(None)),
|
token: Arc::new(RwLock::new(None)),
|
||||||
user_id: Arc::new(RwLock::new(None)),
|
user_id: Arc::new(RwLock::new(None)),
|
||||||
|
library: Arc::new(RwLock::new(MediaLibrary::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +260,17 @@ impl JellyfinClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_authenticated(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if self.token.read().await.is_none() {
|
||||||
|
self.authenticate().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_token(&self) -> Option<String> {
|
||||||
|
self.token.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
|
async fn get_authed_headers(&self) -> Result<HeaderMap, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let token_guard = self.token.read().await;
|
let token_guard = self.token.read().await;
|
||||||
let token = token_guard.as_deref().ok_or("Not authenticated")?;
|
let token = token_guard.as_deref().ok_or("Not authenticated")?;
|
||||||
@@ -268,7 +293,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 +320,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 +329,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 +338,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 +355,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 +378,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(),
|
||||||
@@ -559,7 +568,7 @@ impl JellyfinClient {
|
|||||||
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
|
let mut favorites = fetch_endpoint(&self.client, &url_fav, &headers).await;
|
||||||
sort_media_items_alphabetically(&mut favorites);
|
sort_media_items_alphabetically(&mut favorites);
|
||||||
|
|
||||||
Ok(MediaLibrary {
|
let lib = MediaLibrary {
|
||||||
resume_items,
|
resume_items,
|
||||||
latest_movies,
|
latest_movies,
|
||||||
latest_shows,
|
latest_shows,
|
||||||
@@ -572,7 +581,35 @@ impl JellyfinClient {
|
|||||||
photos,
|
photos,
|
||||||
live_channels,
|
live_channels,
|
||||||
favorites,
|
favorites,
|
||||||
})
|
};
|
||||||
|
*self.library.write().await = lib.clone();
|
||||||
|
Ok(lib)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_library(&self) -> MediaLibrary {
|
||||||
|
{
|
||||||
|
let guard = self.library.read().await;
|
||||||
|
if !guard.all_movies.is_empty() || !guard.all_shows.is_empty() {
|
||||||
|
return guard.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(lib) = self.fetch_all_media().await {
|
||||||
|
return lib;
|
||||||
|
}
|
||||||
|
self.library.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_show_episodes(&self, show_id: &str) -> Vec<MediaItem> {
|
||||||
|
let base_url = self.config.server_url.trim_end_matches('/');
|
||||||
|
if let Ok(headers) = self.get_authed_headers().await {
|
||||||
|
let user_id = self.user_id.read().await.clone().unwrap_or_default();
|
||||||
|
let url = format!(
|
||||||
|
"{}/Shows/{}/Episodes?userId={}&fields=Overview,ProductionYear,CommunityRating,ParentBackdropItemId,ParentPrimaryImageItemId,UserData,IndexNumber,ParentIndexNumber,SortName,Genres",
|
||||||
|
base_url, show_id, user_id
|
||||||
|
);
|
||||||
|
return fetch_endpoint(&self.client, &url, &headers).await;
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +659,8 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
|
|
||||||
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
|
let sort_name = it["SortName"].as_str().map(|s| s.to_string());
|
||||||
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
let index_number = it["IndexNumber"].as_u64().map(|n| n as u32);
|
||||||
|
let parent_index_number = it["ParentIndexNumber"].as_u64().map(|n| n as u32);
|
||||||
|
let season_name = it["SeasonName"].as_str().map(|s| s.to_string());
|
||||||
|
|
||||||
if !id.is_empty() {
|
if !id.is_empty() {
|
||||||
out.push(MediaItem {
|
out.push(MediaItem {
|
||||||
@@ -633,6 +672,8 @@ fn parse_items(json: &serde_json::Value) -> Vec<MediaItem> {
|
|||||||
artist_name,
|
artist_name,
|
||||||
genre,
|
genre,
|
||||||
index_number,
|
index_number,
|
||||||
|
parent_index_number,
|
||||||
|
season_name,
|
||||||
year,
|
year,
|
||||||
overview,
|
overview,
|
||||||
rating,
|
rating,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -33,16 +34,23 @@ 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. Initialize Jellyfin Client
|
||||||
|
let client = JellyfinClient::new(config.jellyfin.clone());
|
||||||
|
|
||||||
|
// 5. Start Yatse Kodi JSON-RPC Server & EventServer
|
||||||
if config.kodi_rpc.enabled {
|
if config.kodi_rpc.enabled {
|
||||||
let yatse_state = YatseServerState {
|
let yatse_state = YatseServerState::new(
|
||||||
player: player.clone(),
|
player.clone(),
|
||||||
nav_tx: nav_tx.clone(),
|
nav_tx.clone(),
|
||||||
};
|
client.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 {
|
||||||
@@ -53,6 +61,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;
|
||||||
@@ -87,8 +99,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
mqtt::MqttService::start(config.clone(), player.clone());
|
mqtt::MqttService::start(config.clone(), player.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Initialize Jellyfin Client
|
|
||||||
let client = JellyfinClient::new(config.jellyfin.clone());
|
|
||||||
|
|
||||||
// 7. Launch Native GUI via eframe (runs on main thread)
|
// 7. Launch Native GUI via eframe (runs on main thread)
|
||||||
let native_options = eframe::NativeOptions {
|
let native_options = eframe::NativeOptions {
|
||||||
@@ -103,40 +113,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,
|
||||||
)))
|
)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
+527
-259
@@ -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,26 +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>>,
|
||||||
|
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),
|
||||||
|
is_render_initialized: AtomicBool::new(false),
|
||||||
|
is_running,
|
||||||
|
event_thread: std::sync::Mutex::new(Some(handle)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,45 +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));
|
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -172,60 +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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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).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(&["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 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;
|
||||||
@@ -241,18 +502,26 @@ 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;
|
self.mpv.cmd(&["seek", &seconds.to_string(), "relative"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seek_percent(&self, percent: f64) {
|
||||||
|
let p = percent.clamp(0.0, 100.0);
|
||||||
|
self.mpv.cmd(&["seek", &p.to_string(), "absolute-percent"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn seek_time(&self, seconds: f64) {
|
||||||
|
let s = seconds.max(0.0);
|
||||||
|
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) {
|
||||||
@@ -260,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) {
|
||||||
@@ -272,223 +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(&["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(&["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(&["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"]);
|
||||||
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"]);
|
||||||
tracing::info!("Cycled audio track");
|
tracing::info!("Cycled audio track");
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn set_audio_track(&self, track_id: i32) {
|
||||||
let mut stream = UnixStream::connect(&self.config.ipc_socket).await?;
|
self.mpv.cmd(&["set", "aid", &track_id.to_string()]);
|
||||||
let cmd_json = serde_json::json!({
|
tracing::info!("Set audio track to aid={}", track_id);
|
||||||
"command": args
|
}
|
||||||
});
|
|
||||||
stream.write_all(format!("{}\n", cmd_json).as_bytes()).await?;
|
pub async fn set_video_visible(&self, _visible: bool) {
|
||||||
Ok(())
|
// No-op with in-process libmpv OpenGL rendering; egui handles visibility directly.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
unsafe {
|
||||||
// Wait for socket to become available
|
libmpv_sys::mpv_wakeup(self.mpv.0);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1408
-56
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -436,6 +454,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: None,
|
genre: None,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
@@ -513,6 +533,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: date_str,
|
genre: date_str,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
@@ -614,6 +636,8 @@ impl YouTubeClient {
|
|||||||
artist_name: None,
|
artist_name: None,
|
||||||
genre: date_str,
|
genre: date_str,
|
||||||
index_number: None,
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
season_name: None,
|
||||||
year: None,
|
year: None,
|
||||||
overview: snip.description,
|
overview: snip.description,
|
||||||
rating: None,
|
rating: None,
|
||||||
|
|||||||
@@ -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/"
|
||||||
|
install -m 755 "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk"
|
||||||
|
echo "==> ✅ Klaar! Binary beschikbaar in:"
|
||||||
|
echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs"
|
||||||
|
echo " - ${KIOSK_DIR}/jellyfin-kiosk"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user