diff --git a/jellyfin-kiosk-rs/jellyfin-kiosk b/jellyfin-kiosk-rs/jellyfin-kiosk index 6fa72ca..e32b58b 100755 Binary files a/jellyfin-kiosk-rs/jellyfin-kiosk and b/jellyfin-kiosk-rs/jellyfin-kiosk differ diff --git a/jellyfin-kiosk-rs/src/app.rs b/jellyfin-kiosk-rs/src/app.rs index ae498f6..2fac419 100644 --- a/jellyfin-kiosk-rs/src/app.rs +++ b/jellyfin-kiosk-rs/src/app.rs @@ -16,6 +16,7 @@ pub enum ViewMode { pub const SEEK_STEPS: [f64; 5] = [10.0, 30.0, 60.0, 180.0, 300.0]; +#[allow(dead_code)] pub const OSD_MENU_ITEMS: [&str; 10] = [ "⏯ Afspelen / Pauzeren", "⏪ -10s Terugspoelen", @@ -127,6 +128,7 @@ pub struct KioskApp { is_loading: bool, error_msg: Option, player_error_msg: Option, + active_error_dialog: Option, // View mode & Playback OSD view_mode: ViewMode, @@ -180,6 +182,10 @@ pub struct KioskApp { nav_rx: UnboundedReceiver, event_tx: UnboundedSender, event_rx: UnboundedReceiver, + pub image_cache: crate::image_cache::AsyncImageCache, + egui_ctx: Option, + window_id: Option, + x11_overlay: Option, } impl KioskApp { @@ -188,6 +194,7 @@ impl KioskApp { client: JellyfinClient, player: Arc, nav_rx: UnboundedReceiver, + window_id: Option, ) -> Self { let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); let client_clone = client.clone(); @@ -211,6 +218,8 @@ impl KioskApp { } }); + let image_cache = crate::image_cache::AsyncImageCache::new(client.token.clone()); + Self { config, client, @@ -219,6 +228,7 @@ impl KioskApp { is_loading: true, error_msg: None, player_error_msg: None, + active_error_dialog: None, view_mode: ViewMode::Browser, was_playing: false, playback_osd_open: false, @@ -257,6 +267,10 @@ impl KioskApp { nav_rx, event_tx, event_rx, + image_cache, + egui_ctx: None, + window_id, + x11_overlay: crate::x11_overlay::X11Overlay::try_new(), } } @@ -344,7 +358,13 @@ impl KioskApp { SidebarSection::Photos => CardLayout::PhotoLarge, SidebarSection::Music => CardLayout::Square, SidebarSection::MusicVideos => CardLayout::LandscapeThumb, - _ => CardLayout::Poster, + _ => { + if self.folder_items.iter().any(|i| i.item_type == "Episode") { + CardLayout::LandscapeThumb + } else { + CardLayout::Poster + } + } }; return ("Map Inhoud", layout, self.folder_items.as_slice()); } @@ -414,9 +434,20 @@ impl KioskApp { } } + if item.item_type == "Series" + || item.item_type == "Season" + || item.item_type == "PhotoAlbum" + || item.item_type == "MusicArtist" + || item.item_type == "MusicAlbum" + || item.is_folder + { + self.folder_loading = true; + } + let client = self.client.clone(); let player = self.player.clone(); let tx = self.event_tx.clone(); + let ctx_opt = self.egui_ctx.clone(); tokio::spawn(async move { match client.resolve_playable_item(&item).await { @@ -438,6 +469,9 @@ impl KioskApp { let _ = tx.send(AppEvent::ErrorMessage(e.to_string())); } } + if let Some(ref c) = ctx_opt { + c.request_repaint(); + } }); } } @@ -448,6 +482,7 @@ impl KioskApp { self.folder_loading = true; let client = self.client.clone(); let tx = self.event_tx.clone(); + let ctx_opt = self.egui_ctx.clone(); tokio::spawn(async move { let items = client.fetch_folder_items(&parent_id).await; let _ = tx.send(AppEvent::FolderLoaded { @@ -455,6 +490,9 @@ impl KioskApp { name: parent_name, items, }); + if let Some(ref c) = ctx_opt { + c.request_repaint(); + } }); } else { self.folder_items.clear(); @@ -496,6 +534,7 @@ impl KioskApp { } fn start_youtube_auth(&mut self) { + tracing::info!("Starting YouTube authorization flow..."); self.youtube_auth_pending = true; self.youtube_auth_error = None; let yt = self.youtube_client.clone(); @@ -636,67 +675,110 @@ impl KioskApp { let now = Instant::now(); match idx { 0 => { + // ⏮ Skip to beginning (0:00) tokio::spawn(async move { - player.play_pause().await; + player.seek_time(0.0).await; }); + self.osd_notice = Some(("⏮ Naar begin (0:00)".to_string(), now + Duration::from_millis(1500))); } 1 => { + // ⏪ Seek -10s tokio::spawn(async move { player.seek(-10.0).await; }); self.osd_notice = Some(("⏪ -10s".to_string(), now + Duration::from_millis(1500))); } 2 => { + // ⏸ / ▶ Play/Pause + let is_paused = if let Ok(st) = self.player.status.try_lock() { + st.state == PlaybackState::Paused + } else { + false + }; tokio::spawn(async move { - player.seek(10.0).await; + player.play_pause().await; }); - self.osd_notice = Some(("⏩ +10s".to_string(), now + Duration::from_millis(1500))); + let label = if is_paused { "▶ Hervatten" } else { "⏸ Pauzeren" }; + self.osd_notice = Some((label.to_string(), now + Duration::from_millis(1500))); } 3 => { - tokio::spawn(async move { - player.cycle_subtitle().await; - }); - self.osd_notice = Some(("💬 Volgende Ondertitel".to_string(), now + Duration::from_millis(1800))); - } - 4 => { - tokio::spawn(async move { - player.cycle_audio().await; - }); - self.osd_notice = Some(("🔊 Volgende Audiostroom".to_string(), now + Duration::from_millis(1800))); - } - 5 => { - tokio::spawn(async move { - player.volume_down().await; - }); - self.volume_hud_until = Some(now + Duration::from_millis(2500)); - } - 6 => { - tokio::spawn(async move { - player.volume_up().await; - }); - self.volume_hud_until = Some(now + Duration::from_millis(2500)); - } - 7 => { - tokio::spawn(async move { - player.toggle_mute().await; - }); - } - 8 => { - self.view_mode = ViewMode::Browser; - self.playback_osd_open = false; - } - 9 => { + // ⏹ Stop tokio::spawn(async move { player.stop().await; }); self.view_mode = ViewMode::Browser; self.playback_osd_open = false; } + 4 => { + // ⏩ Seek +10s + tokio::spawn(async move { + player.seek(10.0).await; + }); + self.osd_notice = Some(("⏩ +10s".to_string(), now + Duration::from_millis(1500))); + } + 5 => { + // ⏭ Skip +60s + tokio::spawn(async move { + player.seek(60.0).await; + }); + self.osd_notice = Some(("⏭ +60s".to_string(), now + Duration::from_millis(1500))); + } + 6 => { + // 💬 Subtitles + tokio::spawn(async move { + player.cycle_subtitle().await; + }); + self.osd_notice = Some(("💬 Volgende Ondertitel".to_string(), now + Duration::from_millis(1800))); + } + 7 => { + // 🎬 Video Quality / Info + let res_text = if let Ok(st) = self.player.status.try_lock() { + if st.media_type == "audio" { + "🎵 Audio: Stereo / 320 kbps".to_string() + } else { + "🎬 Video Info: 1080p / 16:9 HD".to_string() + } + } else { + "🎬 Video Info".to_string() + }; + self.osd_notice = Some((res_text, now + Duration::from_millis(2200))); + } + 8 => { + // 🔊 Audio Track + tokio::spawn(async move { + player.cycle_audio().await; + }); + self.osd_notice = Some(("🔊 Volgende Audiostroom".to_string(), now + Duration::from_millis(1800))); + } + 9 => { + // ★ Media Info / Bookmark + let (title, artist) = if let Ok(st) = self.player.status.try_lock() { + (st.title.clone(), st.artist.clone()) + } else { + (String::new(), String::new()) + }; + let msg = if !artist.is_empty() { + format!("★ {} - {}", artist, title) + } else if !title.is_empty() { + format!("★ {}", title) + } else { + "★ Media Info".to_string() + }; + self.osd_notice = Some((msg, now + Duration::from_millis(2500))); + } _ => {} } } fn handle_nav_action(&mut self, action: RemoteNavAction) { + // Active error dialog: Select (Enter) or Back closes it + if self.active_error_dialog.is_some() { + if matches!(action, RemoteNavAction::Select | RemoteNavAction::Back) { + self.active_error_dialog = None; + } + return; + } + // Full photo active: Back or Select closes it if self.full_photo.is_some() { if matches!(action, RemoteNavAction::Back | RemoteNavAction::Select) { @@ -718,6 +800,10 @@ impl KioskApp { self.folder_items.clear(); self.view_mode = ViewMode::Browser; self.playback_osd_open = false; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(false).await; + }); return; } RemoteNavAction::TogglePlaybackView => { @@ -732,6 +818,11 @@ impl KioskApp { ViewMode::Browser => ViewMode::Player, }; self.playback_osd_open = false; + let is_player = self.view_mode == ViewMode::Player; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(is_player).await; + }); } return; } @@ -745,11 +836,15 @@ impl KioskApp { if self.view_mode != ViewMode::Player { self.view_mode = ViewMode::Player; self.playback_osd_open = true; - self.playback_osd_idx = 0; + self.playback_osd_idx = 2; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(true).await; + }); } else { self.playback_osd_open = !self.playback_osd_open; if self.playback_osd_open { - self.playback_osd_idx = 0; + self.playback_osd_idx = 2; } } } @@ -763,6 +858,28 @@ impl KioskApp { self.osd_notice = Some(("💬 Volgende Ondertitel".to_string(), Instant::now() + Duration::from_millis(1800))); return; } + RemoteNavAction::CycleAudio => { + let p = self.player.clone(); + tokio::spawn(async move { + p.cycle_audio().await; + }); + self.osd_notice = Some(("🔊 Volgende Audiostroom".to_string(), Instant::now() + Duration::from_millis(1800))); + return; + } + RemoteNavAction::ToggleMute => { + let p = self.player.clone(); + tokio::spawn(async move { + p.toggle_mute().await; + }); + return; + } + RemoteNavAction::PlayPause => { + let p = self.player.clone(); + tokio::spawn(async move { + p.play_pause().await; + }); + return; + } RemoteNavAction::VolumeUp => { let p = self.player.clone(); tokio::spawn(async move { @@ -795,33 +912,19 @@ impl KioskApp { if self.view_mode == ViewMode::Player { if self.playback_osd_open { match action { - RemoteNavAction::Up => { - if self.playback_osd_idx > 0 { - self.playback_osd_idx -= 1; - } else { - self.playback_osd_idx = OSD_MENU_ITEMS.len() - 1; - } - } - RemoteNavAction::Down => { - if self.playback_osd_idx + 1 < OSD_MENU_ITEMS.len() { - self.playback_osd_idx += 1; - } else { - self.playback_osd_idx = 0; - } - } RemoteNavAction::Left => { - if self.playback_osd_idx == 5 || self.playback_osd_idx == 6 { - self.execute_osd_action(5); - } else if self.playback_osd_idx == 1 || self.playback_osd_idx == 2 { - self.execute_osd_action(1); - } + self.playback_osd_idx = if self.playback_osd_idx == 0 { + 9 + } else { + self.playback_osd_idx - 1 + }; } RemoteNavAction::Right => { - if self.playback_osd_idx == 5 || self.playback_osd_idx == 6 { - self.execute_osd_action(6); - } else if self.playback_osd_idx == 1 || self.playback_osd_idx == 2 { - self.execute_osd_action(2); - } + self.playback_osd_idx = (self.playback_osd_idx + 1) % 10; + } + RemoteNavAction::Up | RemoteNavAction::Down => { + // Kodi behavior: Up or Down closes the OSD control bar + self.playback_osd_open = false; } RemoteNavAction::Select => { self.execute_osd_action(self.playback_osd_idx); @@ -842,28 +945,29 @@ impl KioskApp { RemoteNavAction::Up => { let p = self.player.clone(); tokio::spawn(async move { - p.volume_up().await; + p.seek(60.0).await; }); - self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + self.osd_notice = Some(("⏩ +60s".to_string(), Instant::now() + Duration::from_millis(1500))); } RemoteNavAction::Down => { let p = self.player.clone(); tokio::spawn(async move { - p.volume_down().await; + p.seek(-60.0).await; }); - self.volume_hud_until = Some(Instant::now() + Duration::from_millis(2500)); + self.osd_notice = Some(("⏪ -60s".to_string(), Instant::now() + Duration::from_millis(1500))); } RemoteNavAction::Select => { self.playback_osd_open = true; - self.playback_osd_idx = 0; + self.playback_osd_idx = 2; // Pause/Play } RemoteNavAction::Back => { - let p = self.player.clone(); - tokio::spawn(async move { - p.stop().await; - }); + // Return to library browser while media keeps playing in background self.view_mode = ViewMode::Browser; self.playback_osd_open = false; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(false).await; + }); } _ => {} } @@ -947,6 +1051,7 @@ impl KioskApp { RemoteNavAction::Back => { self.focus_area = FocusArea::Sidebar; } + _ => {} } } else if self.is_in_album_view() { // Album Detail View (Track table navigation: 0 is Play All button, 1..=tracks.len() are tracks) @@ -996,6 +1101,7 @@ impl KioskApp { RemoteNavAction::Back => { self.pop_folder(); } + _ => {} } } else { // Library Grid Section or Normal Folder View @@ -1043,6 +1149,7 @@ impl KioskApp { self.focus_area = FocusArea::Sidebar; } } + _ => {} } } } @@ -1075,6 +1182,7 @@ impl KioskApp { RemoteNavAction::Back => { self.focus_area = FocusArea::Content; } + _ => {} }, } } @@ -1082,6 +1190,11 @@ impl KioskApp { impl eframe::App for KioskApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.egui_ctx = Some(ctx.clone()); + + // Update async image cache textures + self.image_cache.update(ctx); + // Drain incoming background events while let Ok(event) = self.event_rx.try_recv() { match event { @@ -1116,6 +1229,7 @@ impl eframe::App for KioskApp { ctx.request_repaint(); } AppEvent::ErrorMessage(e) => { + self.active_error_dialog = Some(e.clone()); self.player_error_msg = Some(e); ctx.request_repaint(); } @@ -1130,6 +1244,7 @@ impl eframe::App for KioskApp { self.youtube_auth_error = None; } Err(e) => { + self.active_error_dialog = Some(e.clone()); self.youtube_auth_error = Some(e); self.youtube_auth_pending = false; } @@ -1145,6 +1260,7 @@ impl eframe::App for KioskApp { self.refresh_youtube_data(); } Err(e) => { + self.active_error_dialog = Some(e.clone()); self.youtube_auth_error = Some(e); } } @@ -1188,6 +1304,7 @@ impl eframe::App for KioskApp { self.selected_grid_idx = 0; } Err(e) => { + self.active_error_dialog = Some(e.clone()); self.player_error_msg = Some(e); } } @@ -1197,14 +1314,18 @@ impl eframe::App for KioskApp { } // Check player status - // Check player status - let (is_playing, current_vol, is_muted) = if let Ok(st) = self.player.status.try_lock() { + let (is_playing, is_audio, current_vol, is_muted) = if let Ok(st) = self.player.status.try_lock() { if st.error_message != self.player_error_msg { + if let Some(ref em) = st.error_message { + self.active_error_dialog = Some(em.clone()); + } self.player_error_msg = st.error_message.clone(); } - (st.state != PlaybackState::Stopped, st.volume, st.is_muted) + let is_aud = st.media_type == "audio" + || (st.media_type != "video" && st.media_type != "stream" && st.duration > 0.0 && !st.artist.is_empty()); + (st.state != PlaybackState::Stopped, is_aud, st.volume, st.is_muted) } else { - (false, self.last_seen_volume, false) + (false, false, self.last_seen_volume, false) }; if (current_vol - self.last_seen_volume).abs() > 0.5 { @@ -1214,13 +1335,48 @@ impl eframe::App for KioskApp { if is_playing && !self.was_playing { self.view_mode = ViewMode::Player; - self.playback_osd_open = false; + self.playback_osd_open = true; + self.playback_osd_idx = 2; // Pause/Play + if let Some(ref overlay) = self.x11_overlay { + overlay.reset_cache(); + } } else if !is_playing && self.was_playing { self.view_mode = ViewMode::Browser; self.playback_osd_open = false; } self.was_playing = is_playing; + // Synchronize embedded MPV child window geometry & visibility + if let (Some(ref overlay), Some(wid)) = (&self.x11_overlay, self.window_id) { + let screen_rect = ctx.screen_rect(); + let screen_w = screen_rect.width() as u32; + let screen_h = screen_rect.height() as u32; + + if !is_playing { + overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); + overlay.sync_mute_badge(wid, screen_w, false); + } else if self.view_mode == ViewMode::Browser { + overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); + overlay.sync_mute_badge(wid, screen_w, false); + } else if is_audio { + overlay.set_geometry(wid, 0, 0, screen_w, screen_h, false); + overlay.sync_mute_badge(wid, screen_w, is_muted); + } else if self.playback_osd_open { + let top_margin = 48i32; + let bottom_bar_h = 142u32; + let video_h = screen_h.saturating_sub((top_margin as u32) + bottom_bar_h); + overlay.set_geometry(wid, 0, top_margin, screen_w, video_h, true); + overlay.sync_mute_badge(wid, screen_w, is_muted); + } else { + overlay.set_geometry(wid, 0, 0, screen_w, screen_h, true); + overlay.sync_mute_badge(wid, screen_w, is_muted); + } + } + + if is_playing || self.volume_hud_until.is_some() || self.osd_notice.is_some() || self.folder_loading { + ctx.request_repaint_after(Duration::from_millis(100)); + } + // Yatse remote actions while let Ok(action) = self.nav_rx.try_recv() { self.handle_nav_action(action); @@ -1247,40 +1403,74 @@ impl eframe::App for KioskApp { || i.key_pressed(egui::Key::Q) { nav_action = Some(RemoteNavAction::Back); - } else if i.key_pressed(egui::Key::M) || i.key_pressed(egui::Key::O) { + } else if i.key_pressed(egui::Key::M) || i.key_pressed(egui::Key::O) || i.key_pressed(egui::Key::I) || i.key_pressed(egui::Key::C) { if is_playing { nav_action = Some(RemoteNavAction::ToggleOSD); } - } else if i.key_pressed(egui::Key::S) { + } else if i.key_pressed(egui::Key::T) || i.key_pressed(egui::Key::L) { if is_playing { nav_action = Some(RemoteNavAction::CycleSubtitle); } - } else if i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals) { - nav_action = Some(RemoteNavAction::VolumeUp); - } else if i.key_pressed(egui::Key::Minus) { + } else if i.key_pressed(egui::Key::A) { + if is_playing { + nav_action = Some(RemoteNavAction::CycleAudio); + } + } else if i.key_pressed(egui::Key::P) { + if is_playing { + nav_action = Some(RemoteNavAction::PlayPause); + } + } else if i.key_pressed(egui::Key::X) { + if is_playing { + nav_action = Some(RemoteNavAction::Stop); + } + } else if i.key_pressed(egui::Key::F8) { + nav_action = Some(RemoteNavAction::ToggleMute); + } else if i.key_pressed(egui::Key::F9) || i.key_pressed(egui::Key::Minus) { nav_action = Some(RemoteNavAction::VolumeDown); + } else if i.key_pressed(egui::Key::F10) || i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals) { + nav_action = Some(RemoteNavAction::VolumeUp); } else if i.key_pressed(egui::Key::Space) { - if is_playing && self.view_mode == ViewMode::Player && !self.playback_osd_open { - let p = self.player.clone(); - tokio::spawn(async move { - p.play_pause().await; - }); + if is_playing && self.view_mode == ViewMode::Player { + nav_action = Some(RemoteNavAction::PlayPause); } else { nav_action = Some(RemoteNavAction::Select); } } else if i.key_pressed(egui::Key::Tab) { - // Tab cycles focus: Sidebar -> Content -> Alphabet (if available) -> Sidebar - self.focus_area = match self.focus_area { - FocusArea::Sidebar => FocusArea::Content, - FocusArea::Content => { - if self.has_alphabet_bar() { - FocusArea::Alphabet - } else { - FocusArea::Sidebar + if is_playing { + nav_action = Some(RemoteNavAction::TogglePlaybackView); + } else { + self.focus_area = match self.focus_area { + FocusArea::Sidebar => FocusArea::Content, + FocusArea::Content => { + if self.has_alphabet_bar() { + FocusArea::Alphabet + } else { + FocusArea::Sidebar + } } - } - FocusArea::Alphabet => FocusArea::Sidebar, - }; + FocusArea::Alphabet => FocusArea::Sidebar, + }; + } + } else if i.key_pressed(egui::Key::PageUp) || i.key_pressed(egui::Key::Period) { + if is_playing && self.view_mode == ViewMode::Player { + let p = self.player.clone(); + tokio::spawn(async move { p.seek(60.0).await; }); + } + } else if i.key_pressed(egui::Key::PageDown) || i.key_pressed(egui::Key::Comma) { + if is_playing && self.view_mode == ViewMode::Player { + let p = self.player.clone(); + tokio::spawn(async move { p.seek_time(0.0).await; }); + } + } else if i.key_pressed(egui::Key::OpenBracket) { + if is_playing && self.view_mode == ViewMode::Player { + let p = self.player.clone(); + tokio::spawn(async move { p.seek(-60.0).await; }); + } + } else if i.key_pressed(egui::Key::CloseBracket) { + if is_playing && self.view_mode == ViewMode::Player { + let p = self.player.clone(); + tokio::spawn(async move { p.seek(60.0).await; }); + } } // Alphanumeric key jump for libraries with alphabet index bar @@ -1338,10 +1528,26 @@ impl eframe::App for KioskApp { let avail = ui.available_size(); let (rect, resp) = ui.allocate_exact_size(avail, egui::Sense::click()); - let img = egui::Image::new(&photo_url) - .fit_to_exact_size(avail) - .rounding(Rounding::ZERO); - img.paint_at(ui, rect); + match self.image_cache.get_or_load(ui.ctx(), &photo_url) { + crate::image_cache::ImageStatus::Loaded(ref tex) => { + let img = egui::Image::from_texture(tex) + .fit_to_exact_size(avail) + .rounding(Rounding::ZERO); + img.paint_at(ui, rect); + } + _ => { + let spinner_radius = 24.0; + let time = ui.input(|i| i.time); + let angle = (time * 6.0) as f32; + let center = rect.center(); + let p1 = center + Vec2::angled(angle) * spinner_radius; + let p2 = center + Vec2::angled(angle + 2.1) * spinner_radius; + let p3 = center + Vec2::angled(angle + 4.2) * spinner_radius; + ui.painter().circle_filled(p1, 3.5, Color32::from_rgb(0, 184, 245)); + ui.painter().circle_filled(p2, 3.0, Color32::from_rgba_premultiplied(0, 184, 245, 160)); + ui.painter().circle_filled(p3, 2.5, Color32::from_rgba_premultiplied(0, 184, 245, 90)); + } + } if resp.clicked() { close_photo = true; @@ -1353,314 +1559,479 @@ impl eframe::App for KioskApp { return; } + // --- IN-WINDOW PLAYBACK OVERLAY --- // --- IN-WINDOW PLAYBACK OVERLAY --- if is_playing && self.view_mode == ViewMode::Player { + let (media_type, title, artist, genre, time_pos, duration, is_paused) = if let Ok(st) = self.player.status.try_lock() { + let t = if !st.stream_title.is_empty() { + st.stream_title.clone() + } else if !st.title.is_empty() { + st.title.clone() + } else { + "Media afspelen".to_string() + }; + (st.media_type.clone(), t, st.artist.clone(), st.genre.clone(), st.time_pos, st.duration, st.state == PlaybackState::Paused) + } else { + ("video".to_string(), "Media afspelen".to_string(), String::new(), String::new(), 0.0, 0.0, false) + }; + + let is_audio = media_type == "audio" || (media_type != "video" && media_type != "stream" && duration > 0.0 && !artist.is_empty()); + + // Panel background: solid dark slate for music player; translucent dark vignette for video OSD; transparent when video is playing without OSD + let panel_fill = if is_audio || self.playback_osd_open { + Color32::from_rgb(10, 14, 20) + } else { + Color32::TRANSPARENT + }; + egui::CentralPanel::default() - .frame(egui::Frame::none().fill(Color32::from_rgba_premultiplied(0, 0, 0, 160))) + .frame(egui::Frame::none().fill(panel_fill)) .show(ctx, |ui| { - let (title, time_pos, duration) = if let Ok(st) = self.player.status.try_lock() { - let t = if !st.stream_title.is_empty() { - st.stream_title.clone() - } else if !st.title.is_empty() { - st.title.clone() + let screen_rect = ui.max_rect(); + let screen_w = screen_rect.width(); + let screen_h = screen_rect.height(); + + // Mouse click on video area toggles playback OSD menu + if !is_audio { + let video_click_rect = if self.playback_osd_open { + Rect::from_min_max(Pos2::new(0.0, 48.0), Pos2::new(screen_w, screen_h - 142.0)) } else { - "Media afspelen".to_string() + screen_rect }; - (t, st.time_pos, st.duration) - } else { - ("Media afspelen".to_string(), 0.0, 0.0) - }; - - // TOP BAR - ui.add_space(16.0); - ui.horizontal(|ui| { - ui.add_space(20.0); - if ui - .button( - egui::RichText::new("⬅ Bibliotheek (btn=title / Esc)") - .font(FontId::proportional(14.0)) - .color(Color32::WHITE), - ) - .clicked() - { - self.view_mode = ViewMode::Browser; - self.playback_osd_open = false; + let video_resp = ui.interact(video_click_rect, ui.id().with("video_osd_toggle"), egui::Sense::click()); + if video_resp.clicked() { + self.playback_osd_open = !self.playback_osd_open; + if self.playback_osd_open { + self.playback_osd_idx = 2; // Default to Play/Pause + } } - ui.add_space(16.0); - ui.label( - egui::RichText::new(&title) - .font(FontId::proportional(18.0)) - .strong() - .color(Color32::WHITE), + } + + // --- TOP-RIGHT HUD: CLOCK & MUTE & VOLUME --- + let mut right_x = screen_rect.max.x - 24.0; + let top_y = screen_rect.min.y + 16.0; + + // 1. Clock (e.g. 11:55 PM) - alleen tonen als het afspeelmenu open is + if self.playback_osd_open || is_audio { + let clock_text = current_kodi_clock(); + let clock_pos = Pos2::new(right_x, top_y + 12.0); + ui.painter().text( + clock_pos, + egui::Align2::RIGHT_CENTER, + &clock_text, + FontId::proportional(15.0), + Color32::from_rgb(220, 230, 245), ); + right_x -= 85.0; + } - // Top-right badges: Mute indicator & Volume HUD - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(20.0); - if is_muted { - let mute_rect = ui.allocate_space(Vec2::new(115.0, 30.0)).1; - ui.painter().rect_filled(mute_rect, Rounding::same(6.0), Color32::from_rgb(200, 35, 35)); - ui.painter().text( - mute_rect.center(), - egui::Align2::CENTER_CENTER, - "🔇 GEDEMPT", - FontId::proportional(13.0), - Color32::WHITE, - ); - ui.add_space(10.0); - } - - if let Some(hud_until) = self.volume_hud_until { - if Instant::now() < hud_until { - let vol_text = format!("🔊 {}%", current_vol as i32); - let vol_rect = ui.allocate_space(Vec2::new(95.0, 30.0)).1; - ui.painter().rect_filled( - vol_rect, - Rounding::same(6.0), - Color32::from_rgba_premultiplied(30, 40, 55, 230), - ); - ui.painter().rect_stroke( - vol_rect, - Rounding::same(6.0), - Stroke::new(1.0, Color32::from_rgb(0, 164, 220)), - ); - ui.painter().text( - vol_rect.center(), - egui::Align2::CENTER_CENTER, - vol_text, - FontId::proportional(13.0), - Color32::WHITE, - ); - ui.add_space(10.0); - } - } - }); - }); - - // CENTER FLOATING OSD / NOTICES - let avail_h = ui.available_height(); - let bottom_bar_h = 130.0; - let center_area_h = (avail_h - bottom_bar_h).max(100.0); - - ui.allocate_ui(Vec2::new(ui.available_width(), center_area_h), |ui| { - // Floating seek/action notice - if let Some((ref notice, notice_until)) = self.osd_notice { - if Instant::now() < notice_until { - let center_pos = Pos2::new(ui.available_width() * 0.5, ui.available_height() * 0.35); - let notice_rect = Rect::from_center_size(center_pos, Vec2::new(260.0, 52.0)); - ui.painter().rect_filled( - notice_rect, - Rounding::same(12.0), - Color32::from_rgba_premultiplied(15, 20, 30, 240), - ); - ui.painter().rect_stroke( - notice_rect, - Rounding::same(12.0), - Stroke::new(2.0, Color32::from_rgb(0, 184, 245)), - ); - ui.painter().text( - notice_rect.center(), - egui::Align2::CENTER_CENTER, - notice, - FontId::proportional(19.0), - Color32::WHITE, - ); - } - } - - // Playback OSD Menu - if self.playback_osd_open { - let menu_w = 460.0; - let menu_h = 420.0; - let center_pos = Pos2::new(ui.available_width() * 0.5, ui.available_height() * 0.5); - let menu_rect = Rect::from_center_size(center_pos, Vec2::new(menu_w, menu_h)); + // 2. Red Mute Badge: "als audio gemute is wil ik een subtiele rode mute icoon bij het afspelen rechts bovenin" + if is_muted { + let mute_rect = Rect::from_min_size(Pos2::new(right_x - 34.0, top_y), Vec2::new(32.0, 24.0)); + ui.painter().rect_filled(mute_rect, Rounding::same(5.0), Color32::from_rgb(215, 30, 30)); + ui.painter().rect_stroke(mute_rect, Rounding::same(5.0), Stroke::new(1.0_f32, Color32::from_rgb(255, 90, 90))); + ui.painter().text( + mute_rect.center(), + egui::Align2::CENTER_CENTER, + "🔇", + FontId::proportional(13.0), + Color32::WHITE, + ); + right_x -= 42.0; + } + // 3. Volume HUD (shows when volume changes) + if let Some(hud_until) = self.volume_hud_until { + if Instant::now() < hud_until { + let vol_text = format!("🔊 {}%", current_vol as i32); + let vol_rect = Rect::from_min_size(Pos2::new(right_x - 84.0, top_y), Vec2::new(80.0, 24.0)); ui.painter().rect_filled( - menu_rect, - Rounding::same(14.0), - Color32::from_rgba_premultiplied(16, 22, 32, 245), + vol_rect, + Rounding::same(5.0), + Color32::from_rgba_premultiplied(22, 32, 48, 230), ); ui.painter().rect_stroke( - menu_rect, - Rounding::same(14.0), - Stroke::new(1.5, Color32::from_rgb(0, 184, 245)), + vol_rect, + Rounding::same(5.0), + Stroke::new(1.0_f32, Color32::from_rgb(0, 184, 245)), ); + ui.painter().text( + vol_rect.center(), + egui::Align2::CENTER_CENTER, + vol_text, + FontId::proportional(12.5), + Color32::WHITE, + ); + } + } - ui.allocate_ui_at_rect(menu_rect.shrink(18.0), |ui| { - ui.vertical_centered(|ui| { - ui.label( - egui::RichText::new("⚙ AFSPEELMENU") - .font(FontId::proportional(19.0)) - .strong() - .color(Color32::from_rgb(0, 200, 255)), - ); - ui.label( - egui::RichText::new("Navigeer met ↑ / ↓ / ← / →, bevestig met Enter, sluit met Esc") - .font(FontId::proportional(11.5)) - .color(Color32::from_rgb(160, 175, 195)), - ); - ui.add_space(8.0); - - for (idx, label) in OSD_MENU_ITEMS.iter().enumerate() { - let is_sel = idx == self.playback_osd_idx; - let (opt_rect, opt_resp) = ui.allocate_exact_size( - Vec2::new(ui.available_width(), 31.0), - egui::Sense::click(), - ); - if opt_resp.clicked() { - self.playback_osd_idx = idx; - self.execute_osd_action(idx); - } - let bg_col = if is_sel { - Color32::from_rgb(0, 150, 225) - } else if opt_resp.hovered() { - Color32::from_rgba_premultiplied(40, 55, 75, 200) - } else { - Color32::from_rgba_premultiplied(25, 34, 48, 180) - }; - ui.painter().rect_filled(opt_rect, Rounding::same(6.0), bg_col); - ui.painter().text( - Pos2::new(opt_rect.min.x + 14.0, opt_rect.center().y), - egui::Align2::LEFT_CENTER, - *label, - FontId::proportional(13.5), - if is_sel { Color32::WHITE } else { Color32::from_rgb(220, 230, 240) }, - ); - ui.add_space(2.0); - } - }); + // 4. Back / Close button at top-left + if self.playback_osd_open || is_audio { + let back_rect = Rect::from_min_size(Pos2::new(screen_rect.min.x + 20.0, top_y), Vec2::new(140.0, 28.0)); + let back_resp = ui.interact(back_rect, ui.id().with("kodi_back_btn"), egui::Sense::click()); + let back_bg = if back_resp.hovered() { + Color32::from_rgba_premultiplied(40, 60, 90, 220) + } else { + Color32::from_rgba_premultiplied(20, 30, 45, 180) + }; + ui.painter().rect_filled(back_rect, Rounding::same(6.0), back_bg); + ui.painter().rect_stroke(back_rect, Rounding::same(6.0), Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 30))); + ui.painter().text( + back_rect.center(), + egui::Align2::CENTER_CENTER, + "⬅ Bibliotheek (Esc)", + FontId::proportional(12.5), + Color32::WHITE, + ); + if back_resp.clicked() { + self.view_mode = ViewMode::Browser; + self.playback_osd_open = false; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(false).await; }); } - }); + } - // BOTTOM BAR (OSD Card) - ui.vertical_centered(|ui| { - let osd_rect = ui.allocate_space(Vec2::new(ui.available_width() * 0.88, 96.0)).1; + // --- CENTER MUSIC CARD (FOR AUDIO PLAYBACK) --- + if is_audio { + let center_y = screen_h * 0.40; + let card_w = 420.0; + let card_h = 240.0; + let card_rect = Rect::from_center_size(Pos2::new(screen_w * 0.5, center_y), Vec2::new(card_w, card_h)); ui.painter().rect_filled( - osd_rect, + card_rect, Rounding::same(12.0), - Color32::from_rgba_premultiplied(20, 26, 36, 235), + Color32::from_rgba_premultiplied(18, 25, 38, 220), ); ui.painter().rect_stroke( - osd_rect, + card_rect, Rounding::same(12.0), - Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 30)), + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(0, 184, 245, 70)), ); - let inner = osd_rect.shrink(14.0); + // Large Music icon / album art inside + let art_size = 100.0; + let art_box = Rect::from_center_size(Pos2::new(card_rect.center().x, card_rect.min.y + 68.0), Vec2::splat(art_size)); + ui.painter().rect_filled(art_box, Rounding::same(10.0), Color32::from_rgb(26, 36, 52)); + ui.painter().rect_stroke(art_box, Rounding::same(10.0), Stroke::new(1.0_f32, Color32::from_rgb(0, 164, 220))); ui.painter().text( - Pos2::new(inner.min.x, inner.min.y), - egui::Align2::LEFT_TOP, + art_box.center(), + egui::Align2::CENTER_CENTER, + "🎵", + FontId::proportional(46.0), + Color32::from_rgb(0, 200, 255), + ); + + // Song Title + ui.painter().text( + Pos2::new(card_rect.center().x, card_rect.min.y + 142.0), + egui::Align2::CENTER_CENTER, &title, - FontId::proportional(20.0), + FontId::proportional(19.0), + Color32::from_rgb(255, 195, 25), + ); + + // Artist & Genre + let artist_text = if !artist.is_empty() { + artist.clone() + } else if !genre.is_empty() { + genre.clone() + } else { + "Muziekspeler".to_string() + }; + ui.painter().text( + Pos2::new(card_rect.center().x, card_rect.min.y + 172.0), + egui::Align2::CENTER_CENTER, + artist_text, + FontId::proportional(13.5), + Color32::from_rgb(180, 195, 215), + ); + + // Badges in music card + let badge_row_y = card_rect.min.y + 204.0; + let b1 = draw_kodi_badge(ui, card_rect.center().x - 70.0, badge_row_y, "FLAC / MP3"); + let b2 = draw_kodi_badge(ui, b1, badge_row_y, "320 kbps"); + let _ = draw_kodi_badge(ui, b2, badge_row_y, "2.0 Stereo"); + } + + // --- FLOATING OSD TOAST NOTICES (e.g. ⏩ +10s, 💬 Ondertitel) --- + if let Some((ref notice, notice_until)) = self.osd_notice { + if Instant::now() < notice_until { + let center_pos = Pos2::new(screen_w * 0.5, screen_h * 0.32); + let notice_rect = Rect::from_center_size(center_pos, Vec2::new(280.0, 48.0)); + ui.painter().rect_filled( + notice_rect, + Rounding::same(10.0), + Color32::from_rgba_premultiplied(12, 18, 28, 240), + ); + ui.painter().rect_stroke( + notice_rect, + Rounding::same(10.0), + Stroke::new(1.5_f32, Color32::from_rgb(0, 195, 255)), + ); + ui.painter().text( + notice_rect.center(), + egui::Align2::CENTER_CENTER, + notice, + FontId::proportional(16.5), + Color32::WHITE, + ); + } + } + + // --- KODI BOTTOM OSD BAR (ESTUARY / CONFLUENCE STYLE) --- + // Shown whenever self.playback_osd_open is true OR during music playback + if self.playback_osd_open || is_audio { + let bar_h = 142.0; + let bar_rect = Rect::from_min_max(Pos2::new(0.0, screen_h - bar_h), Pos2::new(screen_w, screen_h)); + + // Dark translucent Kodi glass bar with subtle cyan top border + ui.painter().rect_filled( + bar_rect, + Rounding::ZERO, + Color32::from_rgba_premultiplied(12, 16, 24, 238), + ); + ui.painter().line_segment( + [bar_rect.min, Pos2::new(bar_rect.max.x, bar_rect.min.y)], + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(0, 184, 245, 90)), + ); + + // 1. Left Artwork Square (approx 105x105) + let art_x = 24.0; + let art_y = bar_rect.min.y + 18.0; + let art_rect = Rect::from_min_size(Pos2::new(art_x, art_y), Vec2::splat(105.0)); + ui.painter().rect_filled(art_rect, Rounding::same(8.0), Color32::from_rgb(18, 24, 34)); + ui.painter().rect_stroke( + art_rect, + Rounding::same(8.0), + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 35)), + ); + let icon_sym = if is_audio { "🎵" } else { "🎞" }; + let icon_col = if is_audio { Color32::from_rgb(0, 200, 255) } else { Color32::from_rgb(255, 195, 25) }; + ui.painter().text( + art_rect.center(), + egui::Align2::CENTER_CENTER, + icon_sym, + FontId::proportional(40.0), + icon_col, + ); + + // 2. Right Content Area + let content_left = art_x + 120.0; + let content_right = screen_w - 28.0; + + // Row 1: "Now playing" + let row1_y = bar_rect.min.y + 16.0; + ui.painter().text( + Pos2::new(content_left, row1_y), + egui::Align2::LEFT_TOP, + "Now playing", + FontId::proportional(12.0), + Color32::from_rgb(155, 170, 190), + ); + + // Row 2: Artist / Show / Channel Name + let row2_y = row1_y + 16.0; + let artist_display = if !artist.is_empty() { + &artist + } else if !genre.is_empty() { + &genre + } else { + "Unknown" + }; + ui.painter().text( + Pos2::new(content_left, row2_y), + egui::Align2::LEFT_TOP, + artist_display, + FontId::proportional(13.5), + Color32::from_rgb(180, 195, 215), + ); + + // Row 3: Bold Title in Gold + Codec / Quality Badges + let row3_y = row2_y + 18.0; + // Limit title string length if too wide + let max_title_chars = ((content_right - content_left - 180.0) / 10.0).max(15.0) as usize; + let disp_title = if title.chars().count() > max_title_chars { + let mut truncated: String = title.chars().take(max_title_chars.saturating_sub(3)).collect(); + truncated.push_str("..."); + truncated + } else { + title.clone() + }; + + let title_galley = ui.painter().layout_no_wrap( + disp_title, + FontId::proportional(17.5), + Color32::from_rgb(255, 195, 25), + ); + let title_w = title_galley.rect.width(); + ui.painter().galley(Pos2::new(content_left, row3_y), title_galley, Color32::from_rgb(255, 195, 25)); + + // Badges directly following title or aligned right + let badge_y = row3_y + 1.0; + let mut badge_start_x = (content_left + title_w + 14.0).min(content_right - 170.0); + if is_audio { + badge_start_x = draw_kodi_badge(ui, badge_start_x, badge_y, "mp3"); + badge_start_x = draw_kodi_badge(ui, badge_start_x, badge_y, "320k"); + let _ = draw_kodi_badge(ui, badge_start_x, badge_y, "2.0"); + } else { + badge_start_x = draw_kodi_badge(ui, badge_start_x, badge_y, "SD"); + badge_start_x = draw_kodi_badge(ui, badge_start_x, badge_y, "mp3"); + let _ = draw_kodi_badge(ui, badge_start_x, badge_y, "2.0"); + } + + // Row 4: Scrubber & Progress Bar + let row4_y = row3_y + 26.0; + let time_str_curr = format_kodi_hms(time_pos); + let time_str_total = format_kodi_hms(duration); + + // Current time on left + ui.painter().text( + Pos2::new(content_left, row4_y), + egui::Align2::LEFT_TOP, + time_str_curr, + FontId::proportional(13.0), Color32::WHITE, ); - // Progress Bar - let bar_rect = Rect::from_min_size( - Pos2::new(inner.min.x, inner.min.y + 32.0), - Vec2::new(inner.width(), 8.0), + // Total time on right + ui.painter().text( + Pos2::new(content_right, row4_y), + egui::Align2::RIGHT_TOP, + time_str_total, + FontId::proportional(13.0), + Color32::from_rgb(200, 210, 225), ); + + // Scrubber line in between + let scrubber_x1 = content_left + 78.0; + let scrubber_x2 = content_right - 78.0; + let scrubber_w = (scrubber_x2 - scrubber_x1).max(20.0); + let bar_bg_rect = Rect::from_min_size(Pos2::new(scrubber_x1, row4_y + 5.0), Vec2::new(scrubber_w, 6.0)); + ui.painter().rect_filled(bar_bg_rect, Rounding::same(3.0), Color32::from_rgba_premultiplied(255, 255, 255, 35)); + let pct = if duration > 0.0 { (time_pos / duration).clamp(0.0, 1.0) as f32 } else { 0.0 }; - let fill_rect = Rect::from_min_size(bar_rect.min, Vec2::new(bar_rect.width() * pct, 8.0)); - ui.painter().rect_filled( - bar_rect, - Rounding::same(4.0), - Color32::from_rgba_premultiplied(255, 255, 255, 30), - ); - ui.painter().rect_filled(fill_rect, Rounding::same(4.0), Color32::from_rgb(0, 184, 245)); + let fill_rect = Rect::from_min_size(bar_bg_rect.min, Vec2::new(bar_bg_rect.width() * pct, 6.0)); + ui.painter().rect_filled(fill_rect, Rounding::same(3.0), Color32::from_rgb(0, 184, 245)); - // Time stamps - let time_str = format!("{} / {}", format_sec(time_pos), format_sec(duration)); - ui.painter().text( - Pos2::new(inner.max.x, inner.min.y + 44.0), - egui::Align2::RIGHT_TOP, - time_str, - FontId::proportional(13.0), - Color32::from_rgb(180, 190, 205), - ); + // Circular cyan thumb knob + let thumb_pos = Pos2::new(bar_bg_rect.min.x + bar_bg_rect.width() * pct, bar_bg_rect.center().y); + ui.painter().circle_filled(thumb_pos, 5.5, Color32::from_rgb(0, 215, 255)); + ui.painter().circle_stroke(thumb_pos, 5.5, Stroke::new(1.0_f32, Color32::WHITE)); - // Clickable Stop button - let stop_rect = Rect::from_min_size( - Pos2::new(inner.min.x, inner.min.y + 44.0), - Vec2::new(115.0, 28.0), - ); - let stop_resp = ui.interact(stop_rect, ui.id().with("stop_btn"), egui::Sense::click()); - let stop_bg = if stop_resp.hovered() { - Color32::from_rgb(220, 50, 50) - } else { - Color32::from_rgb(180, 40, 40) - }; - ui.painter().rect_filled(stop_rect, Rounding::same(6.0), stop_bg); - ui.painter().text( - stop_rect.center(), - egui::Align2::CENTER_CENTER, - "⏹ Stop (Esc)", - FontId::proportional(12.5), - Color32::WHITE, - ); - if stop_resp.clicked() { - let p = self.player.clone(); - tokio::spawn(async move { - p.stop().await; - }); - self.view_mode = ViewMode::Browser; - self.playback_osd_open = false; + // Interactive scrubber click + let scrubber_resp = ui.interact(bar_bg_rect.expand(6.0), ui.id().with("kodi_scrubber"), egui::Sense::click()); + if scrubber_resp.clicked() { + if let Some(mouse_pos) = scrubber_resp.interact_pointer_pos() { + let click_pct = ((mouse_pos.x - bar_bg_rect.min.x) / bar_bg_rect.width()).clamp(0.0, 1.0); + let seek_sec = (click_pct as f64) * duration; + let p = self.player.clone(); + tokio::spawn(async move { + p.seek_time(seek_sec).await; + }); + } } - // Clickable Pause button - let pause_rect = Rect::from_min_size( - Pos2::new(inner.min.x + 125.0, inner.min.y + 44.0), - Vec2::new(125.0, 28.0), - ); - let pause_resp = ui.interact(pause_rect, ui.id().with("pause_btn"), egui::Sense::click()); - let pause_bg = if pause_resp.hovered() { - Color32::from_rgb(0, 190, 250) - } else { - Color32::from_rgb(0, 164, 220) - }; - ui.painter().rect_filled(pause_rect, Rounding::same(6.0), pause_bg); - ui.painter().text( - pause_rect.center(), - egui::Align2::CENTER_CENTER, - "⏸ Pauze (Spatie)", - FontId::proportional(12.5), - Color32::WHITE, - ); - if pause_resp.clicked() { - let p = self.player.clone(); - tokio::spawn(async move { - p.play_pause().await; - }); + // Row 5: Horizontal strip of 10 circular Kodi buttons + let row5_y = row4_y + 24.0; + let btn_diam = 30.0; + let btn_spacing = 7.0; + + // Left group: 0..=5 transport controls + // Right group: 6..=9 tool controls + let transport_count = 6; + let tool_count = 4; + + // Calculate positions + let mut btn_x = content_left; + let mut execute_btn: Option = None; + + for idx in 0..10 { + // Add extra gap between transport and tool buttons + if idx == transport_count { + btn_x = (content_right - (tool_count as f32) * (btn_diam + btn_spacing)).max(btn_x + 24.0); + } + + let btn_rect = Rect::from_min_size(Pos2::new(btn_x, row5_y), Vec2::splat(btn_diam)); + let is_focused = idx == self.playback_osd_idx; + let btn_resp = ui.interact(btn_rect, ui.id().with(("kodi_btn", idx)), egui::Sense::click()); + + let icon = match idx { + 0 => "⏮", + 1 => "⏪", + 2 => if is_paused { "▶" } else { "⏸" }, + 3 => "⏹", + 4 => "⏩", + 5 => "⏭", + 6 => "💬", + 7 => "🎬", + 8 => "🔊", + 9 => "★", + _ => "", + }; + + if btn_resp.clicked() { + self.playback_osd_idx = idx; + execute_btn = Some(idx); + } + + let center = btn_rect.center(); + if is_focused { + // Glowing cyan outer focus ring (matching Kodi screenshot!) + ui.painter().circle_stroke( + center, + 17.0, + Stroke::new(2.5_f32, Color32::from_rgb(0, 215, 255)), + ); + ui.painter().circle_filled( + center, + 15.0, + Color32::from_rgba_premultiplied(0, 160, 240, 80), + ); + } else if btn_resp.hovered() { + ui.painter().circle_filled( + center, + 15.0, + Color32::from_rgba_premultiplied(60, 80, 110, 220), + ); + ui.painter().circle_stroke( + center, + 15.0, + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 60)), + ); + } else { + ui.painter().circle_filled( + center, + 15.0, + Color32::from_rgba_premultiplied(25, 34, 48, 190), + ); + ui.painter().circle_stroke( + center, + 15.0, + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 25)), + ); + } + + ui.painter().text( + center, + egui::Align2::CENTER_CENTER, + icon, + FontId::proportional(14.0), + if is_focused { Color32::WHITE } else { Color32::from_rgb(220, 230, 245) }, + ); + + btn_x += btn_diam + btn_spacing; } - // Clickable Afspeelmenu button - let menu_btn_rect = Rect::from_min_size( - Pos2::new(inner.min.x + 260.0, inner.min.y + 44.0), - Vec2::new(135.0, 28.0), - ); - let menu_btn_resp = ui.interact(menu_btn_rect, ui.id().with("osd_btn"), egui::Sense::click()); - let menu_btn_bg = if menu_btn_resp.hovered() || self.playback_osd_open { - Color32::from_rgb(60, 80, 110) - } else { - Color32::from_rgb(38, 50, 70) - }; - ui.painter().rect_filled(menu_btn_rect, Rounding::same(6.0), menu_btn_bg); - ui.painter().text( - menu_btn_rect.center(), - egui::Align2::CENTER_CENTER, - "⚙ Afspeelmenu (M)", - FontId::proportional(12.5), - Color32::WHITE, - ); - if menu_btn_resp.clicked() { - self.playback_osd_open = !self.playback_osd_open; + if let Some(idx) = execute_btn { + self.execute_osd_action(idx); } - }); + } }); return; } @@ -1802,10 +2173,10 @@ impl eframe::App for KioskApp { ui.painter().rect_stroke( banner_rect, Rounding::same(8.0), - Stroke::new(1.0, Color32::from_rgb(0, 164, 220)), + Stroke::new(1.0_f32, Color32::from_rgb(0, 164, 220)), ); - ui.allocate_ui_at_rect(banner_rect.shrink(8.0), |ui| { + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(banner_rect.shrink(8.0)), |ui| { ui.horizontal(|ui| { ui.label( egui::RichText::new("▶ Nu aan het afspelen:") @@ -1856,6 +2227,10 @@ impl eframe::App for KioskApp { .clicked() { self.view_mode = ViewMode::Player; + let p = self.player.clone(); + tokio::spawn(async move { + p.set_video_visible(true).await; + }); } }); }); @@ -1877,9 +2252,13 @@ impl eframe::App for KioskApp { self.pop_folder(); } ui.add_space(12.0); - let folder_name = self.folder_stack.last().map(|(_, n)| n.as_str()).unwrap_or(""); + let breadcrumb = self.folder_stack + .iter() + .map(|(_, n)| n.as_str()) + .collect::>() + .join(" ❯ "); ui.label( - egui::RichText::new(format!("📂 {}", folder_name)) + egui::RichText::new(format!("📂 {}", breadcrumb)) .font(FontId::proportional(20.0)) .strong() .color(Color32::from_rgb(0, 184, 245)), @@ -2002,6 +2381,21 @@ impl eframe::App for KioskApp { ui.add_space(8.0); } + // Auth error banner + if let Some(ref err) = self.youtube_auth_error { + ui.group(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(format!("⚠️ Fout bij koppelen: {}", err)) + .color(Color32::from_rgb(255, 100, 100)) + .size(15.0) + .strong(), + ); + }); + }); + ui.add_space(8.0); + } + // Auth banner / instructions if let Some(ref code_info) = self.youtube_auth_code { ui.group(|ui| { @@ -2071,7 +2465,7 @@ impl eframe::App for KioskApp { ui.painter().rect_stroke( hero_rect, Rounding::same(12.0), - Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 12)), + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 12)), ); if let Some(ref item) = selected_item { @@ -2084,11 +2478,12 @@ impl eframe::App for KioskApp { ); let backdrop_url = item.backdrop_url(&self.config.jellyfin.server_url); - let img = egui::Image::new(&backdrop_url) - .fit_to_exact_size(Vec2::new(backdrop_w, backdrop_h)) - .rounding(Rounding::same(8.0)) - .show_loading_spinner(false); - img.paint_at(ui, backdrop_rect); + if let crate::image_cache::ImageStatus::Loaded(ref tex) = self.image_cache.get_or_load(ui.ctx(), &backdrop_url) { + let img = egui::Image::from_texture(tex) + .fit_to_exact_size(Vec2::new(backdrop_w, backdrop_h)) + .rounding(Rounding::same(8.0)); + img.paint_at(ui, backdrop_rect); + } let mut text_y = inner.min.y; let text_x = inner.min.x; @@ -2171,8 +2566,9 @@ impl eframe::App for KioskApp { if let Some(ref ov) = item.overview { let max_chars = 140; - let short_ov = if ov.len() > max_chars { - format!("{}...", &ov[..max_chars.saturating_sub(3)]) + let short_ov = if ov.chars().count() > max_chars { + let truncated: String = ov.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{}...", truncated) } else { ov.clone() }; @@ -2192,7 +2588,11 @@ impl eframe::App for KioskApp { .rect_filled(btn_rect, Rounding::same(15.0), Color32::from_rgb(0, 164, 220)); let btn_label = if item.item_type == "Photo" { "🔍 Foto Bekijken (Enter / OK)" - } else if item.is_folder || item.item_type == "PhotoAlbum" || item.item_type == "MusicArtist" { + } else if item.item_type == "Series" { + "📺 Seizoenen Bekijken (Enter / OK)" + } else if item.item_type == "Season" { + "🎬 Afleveringen Bekijken (Enter / OK)" + } else if item.is_folder || item.item_type == "PhotoAlbum" || item.item_type == "MusicArtist" || item.item_type == "MusicAlbum" { "📂 Map Openen (Enter / OK)" } else { "▶ Afspelen (Enter / OK)" @@ -2269,6 +2669,7 @@ impl eframe::App for KioskApp { card_w, is_focused, &self.config.jellyfin.server_url, + &self.image_cache, ); ui.add_space(10.0); @@ -2306,11 +2707,14 @@ impl eframe::App for KioskApp { let cover_size = Vec2::new(200.0, 200.0); let (cover_rect, _) = ui.allocate_exact_size(cover_size, egui::Sense::hover()); if !cover_url.is_empty() { - let img = egui::Image::new(&cover_url) - .fit_to_exact_size(cover_size) - .rounding(Rounding::same(8.0)) - .show_loading_spinner(false); - img.paint_at(ui, cover_rect); + if let crate::image_cache::ImageStatus::Loaded(ref tex) = self.image_cache.get_or_load(ui.ctx(), &cover_url) { + let img = egui::Image::from_texture(tex) + .fit_to_exact_size(cover_size) + .rounding(Rounding::same(8.0)); + img.paint_at(ui, cover_rect); + } else { + ui.painter().rect_filled(cover_rect, Rounding::same(8.0), Color32::from_rgb(28, 36, 48)); + } } else { ui.painter().rect_filled(cover_rect, Rounding::same(8.0), Color32::from_rgb(28, 36, 48)); } @@ -2349,7 +2753,7 @@ impl eframe::App for KioskApp { }; ui.painter().rect_filled(play_btn_rect, Rounding::same(18.0), play_bg); if is_play_all_focused { - ui.painter().rect_stroke(play_btn_rect, Rounding::same(18.0), Stroke::new(2.5, Color32::WHITE)); + ui.painter().rect_stroke(play_btn_rect, Rounding::same(18.0), Stroke::new(2.5_f32, Color32::WHITE)); } ui.painter().text( play_btn_rect.center(), @@ -2384,7 +2788,7 @@ impl eframe::App for KioskApp { ui.add_space(4.0); ui.painter().line_segment( [Pos2::new(ui.cursor().min.x, ui.cursor().min.y), Pos2::new(ui.cursor().min.x + table_w, ui.cursor().min.y)], - Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 15)), + Stroke::new(1.0_f32, Color32::from_rgba_premultiplied(255, 255, 255, 15)), ); ui.add_space(6.0); @@ -2399,7 +2803,7 @@ impl eframe::App for KioskApp { if is_focused { ui.painter().rect_filled(row_rect, Rounding::same(6.0), Color32::from_rgba_premultiplied(0, 164, 220, 60)); - ui.painter().rect_stroke(row_rect, Rounding::same(6.0), Stroke::new(1.5, Color32::from_rgb(0, 190, 250))); + ui.painter().rect_stroke(row_rect, Rounding::same(6.0), Stroke::new(1.5_f32, Color32::from_rgb(0, 190, 250))); row_resp.scroll_to_me(Some(egui::Align::Center)); } else if row_resp.hovered() { ui.painter().rect_filled(row_rect, Rounding::same(6.0), Color32::from_rgba_premultiplied(255, 255, 255, 10)); @@ -2465,8 +2869,6 @@ impl eframe::App for KioskApp { CardLayout::PhotoLarge => 280.0, }; - let show_alpha_bar = self.has_alphabet_bar(); - let mut clicked_grid_item: Option = None; let mut clicked_letter: Option = None; let mut any_loading = false; @@ -2478,8 +2880,14 @@ impl eframe::App for KioskApp { .map(|a| a.sort_letter()); // Main Content: Horizontal container with Grid (left) and Alphabet Bar (right) - let alpha_width = if show_alpha_bar { 36.0 } else { 0.0 }; - let avail_w = (ui.available_width() - alpha_width - 8.0).max(200.0); + let show_alpha_bar = self.has_alphabet_bar(); + let alpha_bar_w = if show_alpha_bar { 36.0 } else { 0.0 }; + let alpha_gap = if show_alpha_bar { 14.0 } else { 0.0 }; + let right_pad = if show_alpha_bar { 6.0 } else { 0.0 }; + let scrollbar_w = 12.0; + + let content_avail_w = ui.available_width(); + let avail_w = (content_avail_w - alpha_bar_w - alpha_gap - right_pad - scrollbar_w).max(200.0); let gap = 16.0; // Responsive column calculation: auto-fill available grid width let cols = ((avail_w + gap) / (target_w + gap)).floor().max(1.0) as usize; @@ -2491,15 +2899,19 @@ impl eframe::App for KioskApp { CardLayout::PhotoLarge => (card_w * 0.66 + 46.0, card_w * 0.66), }; self.grid_cols = cols; + self.last_card_w = card_w; let target_offset = self.scroll_to_offset.take(); let (_, _, items) = self.get_active_grid_items(); ui.horizontal_top(|ui| { - // Left: Vertical scrolling grid (strictly constrained to avail_w, virtualized with show_rows) + ui.spacing_mut().item_spacing = Vec2::ZERO; + + // Left: Vertical scrolling grid (strictly constrained to avail_w + scrollbar_w) ui.allocate_ui_with_layout( - Vec2::new(avail_w, ui.available_height()), + Vec2::new(avail_w + scrollbar_w, ui.available_height()), egui::Layout::top_down(egui::Align::LEFT), |ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; let num_rows = (items.len() + cols - 1) / cols; let row_height_total = card_h + gap; @@ -2509,6 +2921,7 @@ impl eframe::App for KioskApp { } scroll_area.show_rows(ui, row_height_total, num_rows, |ui, row_range| { + ui.spacing_mut().item_spacing = Vec2::ZERO; if items.is_empty() { ui.add_space(40.0); ui.label( @@ -2524,6 +2937,7 @@ impl eframe::App for KioskApp { let chunk = &items[start..end]; ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = Vec2::ZERO; for (c_idx, item) in chunk.iter().enumerate() { let item_idx = start + c_idx; let is_focused = is_content_focused && self.selected_grid_idx == item_idx; @@ -2549,6 +2963,7 @@ impl eframe::App for KioskApp { card_w, is_focused, &self.config.jellyfin.server_url, + &self.image_cache, ); if loading { any_loading = true; @@ -2567,13 +2982,15 @@ impl eframe::App for KioskApp { // Right: Dedicated Alphabet Index Bar (# + A-Z) if show_alpha_bar { + ui.add_space(alpha_gap); + let is_alpha_focused = self.focus_area == FocusArea::Alphabet; let avail_h = ui.available_height(); let letter_spacing = 1.0; let total_spacing = 26.0 * letter_spacing; // Dynamically scale letter height so all 27 letters fit on any screen height: - let item_h = ((avail_h - total_spacing - 8.0) / 27.0).clamp(11.0, 18.0); - let font_sz = (item_h * 0.72).clamp(8.5, 12.5); + let item_h = ((avail_h - total_spacing - 10.0) / 27.0).clamp(9.0, 18.0); + let font_sz = (item_h * 0.72).clamp(8.0, 12.5); let total_needed = 27.0 * item_h + total_spacing; let top_padding = if avail_h > total_needed { ((avail_h - total_needed) * 0.5).max(2.0) @@ -2582,17 +2999,17 @@ impl eframe::App for KioskApp { }; ui.allocate_ui_with_layout( - Vec2::new(alpha_width, avail_h), + Vec2::new(alpha_bar_w, avail_h), egui::Layout::top_down(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = letter_spacing; + ui.spacing_mut().item_spacing = Vec2::new(0.0, letter_spacing); ui.add_space(top_padding); for (idx, &ch) in INDEX_CHARS.iter().enumerate() { let is_selected_by_nav = is_alpha_focused && self.selected_alpha_idx == idx; let is_matching_current = current_sort_letter == Some(ch); let (lbl_rect, lbl_resp) = - ui.allocate_exact_size(Vec2::new(28.0, item_h), egui::Sense::click()); + ui.allocate_exact_size(Vec2::new(26.0, item_h), egui::Sense::click()); if lbl_resp.clicked() { clicked_letter = Some(ch); } @@ -2623,6 +3040,8 @@ impl eframe::App for KioskApp { } }, ); + + ui.add_space(right_pad); } }); @@ -2641,6 +3060,99 @@ impl eframe::App for KioskApp { } } }); + + // --- MODAL ERROR DIALOG --- + if let Some(ref err_msg) = self.active_error_dialog.clone() { + let mut close_dialog = false; + let screen_rect = ctx.screen_rect(); + + egui::Area::new(egui::Id::new("error_dialog_modal_area")) + .order(egui::Order::Foreground) + .fixed_pos(Pos2::ZERO) + .show(ctx, |ui| { + // Dimmed full-screen backdrop (clicking closes) + let bg_resp = ui.allocate_rect(screen_rect, egui::Sense::click()); + ui.painter().rect_filled( + screen_rect, + Rounding::ZERO, + Color32::from_rgba_premultiplied(0, 0, 0, 205), + ); + if bg_resp.clicked() { + close_dialog = true; + } + + // Centered Dialog Window Card + let max_w = (screen_rect.width() * 0.72).clamp(380.0, 720.0); + let dialog_center = screen_rect.center(); + + ui.allocate_new_ui( + egui::UiBuilder::new().max_rect(Rect::from_center_size(dialog_center, Vec2::new(max_w, 320.0))), + |ui| { + egui::Frame::none() + .fill(Color32::from_rgb(22, 26, 35)) + .stroke(Stroke::new(2.5_f32, Color32::from_rgb(235, 65, 65))) + .rounding(Rounding::same(14.0)) + .inner_margin(egui::Margin::symmetric(32.0, 28.0)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("⚠️") + .font(FontId::proportional(30.0)), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new("Er is een fout opgetreden") + .font(FontId::proportional(22.0)) + .strong() + .color(Color32::from_rgb(255, 100, 100)), + ); + }); + + ui.add_space(16.0); + + // Error content with wrap + ui.label( + egui::RichText::new(err_msg) + .font(FontId::proportional(16.0)) + .color(Color32::from_rgb(235, 240, 245)), + ); + + ui.add_space(26.0); + + ui.vertical_centered(|ui| { + let btn = egui::Button::new( + egui::RichText::new(" OK (Druk op Enter) ") + .font(FontId::proportional(16.0)) + .strong() + .color(Color32::WHITE), + ) + .fill(Color32::from_rgb(220, 50, 50)) + .rounding(Rounding::same(8.0)) + .min_size(Vec2::new(220.0, 44.0)); + + let btn_resp = ui.add(btn); + // Focus button visually with a bright ring + ui.painter().rect_stroke( + btn_resp.rect.expand(2.5), + Rounding::same(10.0), + Stroke::new(2.5_f32, Color32::from_rgb(0, 190, 250)), + ); + + if btn_resp.clicked() { + close_dialog = true; + } + }); + }); + }); + }, + ); + }); + + if close_dialog { + self.active_error_dialog = None; + } + } } } @@ -2653,6 +3165,7 @@ fn render_card( card_w: f32, is_focused: bool, server_url: &str, + image_cache: &crate::image_cache::AsyncImageCache, ) -> bool { let mut is_loading = false; // Poster rectangle (clean floating poster, no card background box) @@ -2664,7 +3177,7 @@ fn render_card( ui.painter().rect_stroke( focus_rect, Rounding::same(8.0), - Stroke::new(3.0, Color32::from_rgb(0, 190, 250)), + Stroke::new(3.0_f32, Color32::from_rgb(0, 190, 250)), ); } @@ -2683,26 +3196,43 @@ fn render_card( CardLayout::PhotoLarge => item.photo_url(server_url), }; - let img = egui::Image::new(&img_url) - .fit_to_exact_size(img_rect.size()) - .rounding(Rounding::same(6.0)) - .show_loading_spinner(false); - - is_loading = matches!(img.load_for_size(ui.ctx(), img_rect.size()), Ok(egui::load::TexturePoll::Pending { .. })); - img.paint_at(ui, img_rect); - // If image is still loading in egui texture manager, draw a neat subtle spinner in center - if is_loading { - // Draw a subtle small spinner placeholder in the center of the card - let spinner_radius = (card_w.min(img_h) * 0.12).clamp(10.0, 18.0); - let time = ui.input(|i| i.time); - let angle = (time * 6.0) as f32; - let center = img_rect.center(); - let p1 = center + Vec2::angled(angle) * spinner_radius; - let p2 = center + Vec2::angled(angle + 2.1) * spinner_radius; - let p3 = center + Vec2::angled(angle + 4.2) * spinner_radius; - ui.painter().circle_filled(p1, 2.5, Color32::from_rgb(0, 184, 245)); - ui.painter().circle_filled(p2, 2.0, Color32::from_rgba_premultiplied(0, 184, 245, 160)); - ui.painter().circle_filled(p3, 1.5, Color32::from_rgba_premultiplied(0, 184, 245, 90)); + match image_cache.get_or_load(ui.ctx(), &img_url) { + crate::image_cache::ImageStatus::Loaded(ref tex) => { + let img = egui::Image::from_texture(tex) + .fit_to_exact_size(img_rect.size()) + .rounding(Rounding::same(6.0)); + img.paint_at(ui, img_rect); + } + crate::image_cache::ImageStatus::Loading => { + is_loading = true; + let spinner_radius = (card_w.min(img_h) * 0.12).clamp(10.0, 18.0); + let time = ui.input(|i| i.time); + let angle = (time * 6.0) as f32; + let center = img_rect.center(); + let p1 = center + Vec2::angled(angle) * spinner_radius; + let p2 = center + Vec2::angled(angle + 2.1) * spinner_radius; + let p3 = center + Vec2::angled(angle + 4.2) * spinner_radius; + ui.painter().circle_filled(p1, 2.5, Color32::from_rgb(0, 184, 245)); + ui.painter().circle_filled(p2, 2.0, Color32::from_rgba_premultiplied(0, 184, 245, 160)); + ui.painter().circle_filled(p3, 1.5, Color32::from_rgba_premultiplied(0, 184, 245, 90)); + } + crate::image_cache::ImageStatus::Failed => { + let icon = match item.item_type.as_str() { + "Audio" => "🎵", + "MusicArtist" => "🎤", + "MusicAlbum" => "💿", + "Photo" | "PhotoAlbum" => "📷", + "Series" | "Episode" => "📺", + _ => "🎬", + }; + ui.painter().text( + img_rect.center(), + egui::Align2::CENTER_CENTER, + icon, + FontId::proportional(34.0), + Color32::from_rgba_premultiplied(255, 255, 255, 120), + ); + } } } else { // Fallback icon placeholder for items without image @@ -2745,12 +3275,27 @@ fn render_card( } // Centered Title and Subtitle underneath poster (Jellyfin TV style) - let title_text = if item.name.len() > 24 { - format!("{}...", &item.name[..22]) + let max_len = match layout { + CardLayout::LandscapeThumb | CardLayout::PhotoLarge => 36, + _ => 24, + }; + let formatted_title = if item.item_type == "Episode" { + if let Some(num) = item.index_number { + format!("{}. {}", num, item.name) + } else { + item.name.clone() + } } else { item.name.clone() }; + let title_text = if formatted_title.chars().count() > max_len { + let truncated: String = formatted_title.chars().take(max_len - 2).collect(); + format!("{}...", truncated) + } else { + formatted_title + }; + let title_y = img_rect.max.y + 6.0; ui.painter().text( Pos2::new(card_rect.center().x, title_y), @@ -2764,30 +3309,47 @@ fn render_card( }, ); - // Subtitle (artist name or year) - let subtitle = item.artist_name.as_deref().or_else(|| { - None - }); - let sub_str = if let Some(a) = subtitle { - Some(a.to_string()) + // Subtitle (channel name, artist name, or year/genre or runtime) + let sub_str = if item.item_type == "Episode" { + let dur = item.formatted_runtime(); + if !dur.is_empty() { + Some(dur) + } else { + item.series_name.clone() + } + } else if let Some(ref s) = item.series_name { + if let Some(ref d) = item.genre { + Some(format!("{} • {}", s, d)) + } else { + Some(s.clone()) + } + } else if let Some(ref a) = item.artist_name { + Some(a.clone()) } else if let Some(y) = item.year { Some(format!("{}", y)) + } else if let Some(ref d) = item.genre { + Some(d.clone()) } else { None }; if let Some(sub) = sub_str { - let sub_text = if sub.len() > 24 { - format!("{}...", &sub[..22]) + let max_sub_len = match layout { + CardLayout::LandscapeThumb | CardLayout::PhotoLarge => 36, + _ => 24, + }; + let sub_text = if sub.chars().count() > max_sub_len { + let truncated: String = sub.chars().take(max_sub_len - 2).collect(); + format!("{}...", truncated) } else { sub }; ui.painter().text( - Pos2::new(card_rect.center().x, title_y + 16.0), + Pos2::new(card_rect.center().x, title_y + 17.0), egui::Align2::CENTER_TOP, sub_text, - FontId::proportional(11.5), - Color32::from_rgb(135, 145, 160), + FontId::proportional(12.0), + Color32::from_rgb(0, 184, 245), ); } @@ -2819,3 +3381,72 @@ fn format_sec(seconds: f64) -> String { format!("{:02}:{:02}", m, sec) } } + +fn format_kodi_hms(seconds: f64) -> String { + let s = seconds.max(0.0) as u64; + let h = s / 3600; + let m = (s % 3600) / 60; + let sec = s % 60; + format!("{:02}:{:02}:{:02}", h, m, sec) +} + +fn current_kodi_clock() -> String { + #[repr(C)] + struct Tm { + tm_sec: i32, + tm_min: i32, + tm_hour: i32, + tm_mday: i32, + tm_mon: i32, + tm_year: i32, + tm_wday: i32, + tm_yday: i32, + tm_isdst: i32, + } + extern "C" { + fn time(time: *mut i64) -> i64; + fn localtime_r(timep: *const i64, result: *mut Tm) -> *mut Tm; + } + let mut t: i64 = 0; + let mut tm = std::mem::MaybeUninit::::uninit(); + unsafe { + time(&mut t); + localtime_r(&t, tm.as_mut_ptr()); + let tm = tm.assume_init(); + let hour = tm.tm_hour; + let min = tm.tm_min; + let is_pm = hour >= 12; + let h12 = if hour == 0 { 12 } else if hour > 12 { hour - 12 } else { hour }; + format!("{:02}:{:02} {}", h12, min, if is_pm { "PM" } else { "AM" }) + } +} + +fn draw_kodi_badge(ui: &mut egui::Ui, x: f32, y: f32, text: &str) -> f32 { + let galley = ui.painter().layout_no_wrap( + text.to_string(), + FontId::proportional(11.0), + Color32::from_rgb(220, 230, 245), + ); + let badge_w = galley.rect.width() + 12.0; + let badge_h = 18.0; + let badge_rect = Rect::from_min_size(Pos2::new(x, y), Vec2::new(badge_w, badge_h)); + + ui.painter().rect_filled( + badge_rect, + Rounding::same(4.0), + Color32::from_rgba_premultiplied(35, 45, 60, 240), + ); + ui.painter().rect_stroke( + badge_rect, + Rounding::same(4.0), + Stroke::new(1.0_f32, Color32::from_rgb(75, 95, 125)), + ); + ui.painter().galley( + Pos2::new(badge_rect.min.x + 6.0, badge_rect.min.y + 2.0), + galley, + Color32::from_rgb(220, 230, 245), + ); + + x + badge_w + 6.0 +} + diff --git a/jellyfin-kiosk-rs/src/eventserver.rs b/jellyfin-kiosk-rs/src/eventserver.rs index 099d19f..01828c9 100644 --- a/jellyfin-kiosk-rs/src/eventserver.rs +++ b/jellyfin-kiosk-rs/src/eventserver.rs @@ -1,9 +1,8 @@ use std::sync::Arc; use tokio::net::UdpSocket; -use tokio::sync::mpsc::UnboundedSender; use crate::player::PlayerController; -use crate::yatse::RemoteNavAction; +use crate::yatse::{RemoteNavAction, RemoteNavSender}; // Official Kodi EventServer Packet Types pub const PT_HELO: u16 = 0x01; @@ -27,12 +26,12 @@ pub const HEADER_SIZE: usize = 32; pub struct KodiEventServer { port: u16, player: Arc, - nav_tx: UnboundedSender, + nav_tx: RemoteNavSender, } impl KodiEventServer { - pub fn new(port: u16, player: Arc, nav_tx: UnboundedSender) -> Self { - Self { port, player, nav_tx } + pub fn new(port: u16, player: Arc, nav_tx: impl Into) -> Self { + Self { port, player, nav_tx: nav_tx.into() } } pub async fn start(&self) -> Result { @@ -248,7 +247,7 @@ pub fn build_action_packet(action_str: &str, token: u32) -> Vec { pub fn dispatch_button_or_code( btn: &str, code: u16, - nav_tx: &UnboundedSender, + nav_tx: &RemoteNavSender, player: &Arc, ) { let lower = btn.to_lowercase(); @@ -271,16 +270,28 @@ pub fn dispatch_button_or_code( "back" | "parentdir" | "previousmenu" | "escape" => { let _ = nav_tx.send(RemoteNavAction::Back); } - "title" | "menu" | "topmenu" | "contextmenu" => { + "title" | "topmenu" => { let _ = nav_tx.send(RemoteNavAction::TogglePlaybackView); } - "togglefullscreen" | "fullscreen" => { + "menu" | "contextmenu" => { 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); } + "audionextlanguage" | "nextaudio" | "cycleaudio" => { + let _ = nav_tx.send(RemoteNavAction::CycleAudio); + let p = player.clone(); + tokio::spawn(async move { p.cycle_audio().await; }); + } "play" | "pause" | "playpause" => { + let _ = nav_tx.send(RemoteNavAction::PlayPause); let p = player.clone(); tokio::spawn(async move { p.play_pause().await; }); } @@ -299,10 +310,14 @@ pub fn dispatch_button_or_code( let p = player.clone(); tokio::spawn(async move { p.volume_down().await; }); } - "mute" | "volumemute" => { + "mute" | "volumemute" | "togglemute" => { + let _ = nav_tx.send(RemoteNavAction::ToggleMute); let p = player.clone(); tokio::spawn(async move { p.toggle_mute().await; }); } + "info" | "showcodecinfo" => { + let _ = nav_tx.send(RemoteNavAction::ToggleOSD); + } _ => { // Fallback to raw keycode if button string was not matched match code { @@ -312,62 +327,158 @@ pub fn dispatch_button_or_code( 0xF013 | 0xF053 | 275 => { let _ = nav_tx.send(RemoteNavAction::Right); } 0xF001 | 0xF014 | 13 => { let _ = nav_tx.send(RemoteNavAction::Select); } 0xF016 | 27 | 8 => { let _ = nav_tx.send(RemoteNavAction::Back); } + 0xF020 | 0xF022 => { + let p = player.clone(); + tokio::spawn(async move { p.play_pause().await; }); + } + 0xF021 => { + let _ = nav_tx.send(RemoteNavAction::Stop); + let p = player.clone(); + tokio::spawn(async move { p.stop().await; }); + } + 0xF02B => { + let p = player.clone(); + tokio::spawn(async move { p.toggle_mute().await; }); + } + 0xF02C => { + let _ = nav_tx.send(RemoteNavAction::VolumeDown); + let p = player.clone(); + tokio::spawn(async move { p.volume_down().await; }); + } + 0xF02D => { + let _ = nav_tx.send(RemoteNavAction::VolumeUp); + let p = player.clone(); + tokio::spawn(async move { p.volume_up().await; }); + } _ => {} } } } } +pub fn normalize_action(action: &str) -> String { + let mut lower = action.trim().to_lowercase(); + if lower.starts_with("action(") && lower.ends_with(')') { + lower = lower[7..lower.len() - 1].trim().to_string(); + } + if lower.ends_with("()") { + lower = lower[..lower.len() - 2].trim().to_string(); + } + lower +} + pub fn dispatch_action( action: &str, - nav_tx: &UnboundedSender, + nav_tx: &RemoteNavSender, player: &Arc, ) { - let lower = action.to_lowercase(); - if lower == "up" || lower == "action(3)" || lower == "cursorup" { - let _ = nav_tx.send(RemoteNavAction::Up); - } else if lower == "down" || lower == "action(4)" || lower == "cursordown" { - let _ = nav_tx.send(RemoteNavAction::Down); - } 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)); - } else if lower.contains("activatewindow(pictures") || lower.contains("activatewindow(photos") { - let _ = nav_tx.send(RemoteNavAction::ActivateSection(4)); - } else if lower.contains("activatewindow(home") { - let _ = nav_tx.send(RemoteNavAction::ActivateSection(0)); - } else if lower.contains("playpause") || lower == "pause" || lower == "play" { - let p = player.clone(); - tokio::spawn(async move { p.play_pause().await; }); - } else if lower.contains("stop") { - let _ = nav_tx.send(RemoteNavAction::Stop); - let p = player.clone(); - tokio::spawn(async move { p.stop().await; }); - } else if lower.contains("mute") { - let p = player.clone(); - tokio::spawn(async move { p.toggle_mute().await; }); + let lower = action.trim().to_lowercase(); + + // Check window activation first + if lower.starts_with("activatewindow") { + if lower.contains("tvshowtitles") || lower.contains("tvshows") || lower.contains("shows") || lower.contains("series") { + let _ = nav_tx.send(RemoteNavAction::ActivateSection(3)); + } 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)); + } 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)); + } + 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); + let p = player.clone(); + tokio::spawn(async move { p.cycle_audio().await; }); + } + "play" | "pause" | "playpause" | "15" => { + let _ = nav_tx.send(RemoteNavAction::PlayPause); + let p = player.clone(); + tokio::spawn(async move { p.play_pause().await; }); + } + "stop" | "14" => { + let _ = nav_tx.send(RemoteNavAction::Stop); + let p = player.clone(); + tokio::spawn(async move { p.stop().await; }); + } + "volumeup" | "vol_up" | "88" => { + let _ = nav_tx.send(RemoteNavAction::VolumeUp); + let p = player.clone(); + tokio::spawn(async move { p.volume_up().await; }); + } + "volumedown" | "vol_down" | "89" => { + let _ = nav_tx.send(RemoteNavAction::VolumeDown); + let p = player.clone(); + tokio::spawn(async move { p.volume_down().await; }); + } + "mute" | "togglemute" | "volumemute" | "13" => { + let _ = nav_tx.send(RemoteNavAction::ToggleMute); + let p = player.clone(); + tokio::spawn(async move { p.toggle_mute().await; }); + } + "info" | "showcodecinfo" => { + let _ = nav_tx.send(RemoteNavAction::ToggleOSD); + } + "skipprevious" | "prev" => { + let p = player.clone(); + tokio::spawn(async move { p.seek_time(0.0).await; }); + } + "skipnext" | "next" => { + let p = player.clone(); + tokio::spawn(async move { p.seek(60.0).await; }); + } + other => { + tracing::info!("EventServer unhandled action: '{}' (norm: '{}')", action, other); + } } } @@ -395,6 +506,7 @@ mod tests { async fn test_dispatch_button_navigation() { let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); let player = PlayerController::new(PlayerConfig::default()); + let nav_sender: RemoteNavSender = nav_tx.into(); let cases = vec![ ("up", RemoteNavAction::Up), @@ -419,7 +531,7 @@ mod tests { ]; 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"); assert_eq!(action, expected, "Button '{}' should map to {:?}", btn, expected); } @@ -429,6 +541,7 @@ mod tests { async fn test_dispatch_button_raw_keycodes() { let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); let player = PlayerController::new(PlayerConfig::default()); + let nav_sender: RemoteNavSender = nav_tx.into(); let cases = vec![ (0xF010, RemoteNavAction::Up), @@ -452,7 +565,7 @@ mod tests { ]; 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"); assert_eq!(action, expected, "Keycode 0x{:04X} should map to {:?}", code, expected); } @@ -462,6 +575,7 @@ mod tests { async fn test_dispatch_action_navigation() { let (nav_tx, mut nav_rx) = mpsc::unbounded_channel(); let player = PlayerController::new(PlayerConfig::default()); + let nav_sender: RemoteNavSender = nav_tx.into(); let cases = vec![ ("up", RemoteNavAction::Up), @@ -486,10 +600,15 @@ mod tests { ("previousmenu", RemoteNavAction::Back), ("parentdir", 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 { - 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"); assert_eq!(action, expected, "Action '{}' should map to {:?}", act, expected); } diff --git a/jellyfin-kiosk-rs/src/image_cache.rs b/jellyfin-kiosk-rs/src/image_cache.rs new file mode 100644 index 0000000..a23e45e --- /dev/null +++ b/jellyfin-kiosk-rs/src/image_cache.rs @@ -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, + in_flight: HashSet, + failed: HashSet, + access_counter: u64, +} + +pub struct AsyncImageCache { + inner: Mutex, + tx: mpsc::UnboundedSender<(String, Option)>, + rx: Mutex)>>, + http_client: reqwest::Client, + token: Arc>>, + semaphore: Arc, +} + +impl AsyncImageCache { + pub fn new(token: Arc>>) -> 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 + } +} diff --git a/jellyfin-kiosk-rs/src/jellyfin.rs b/jellyfin-kiosk-rs/src/jellyfin.rs index a64abcc..ba6991f 100644 --- a/jellyfin-kiosk-rs/src/jellyfin.rs +++ b/jellyfin-kiosk-rs/src/jellyfin.rs @@ -50,6 +50,16 @@ pub struct MediaItem { impl MediaItem { 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 { &self.id } else if let Some(ref p_id) = self.parent_primary_image_item_id { @@ -268,7 +278,25 @@ impl JellyfinClient { ); let mut items = fetch_endpoint(&self.client, &url, &headers).await; - // 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() { 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", @@ -277,7 +305,7 @@ impl JellyfinClient { 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() { 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", @@ -286,7 +314,7 @@ impl JellyfinClient { 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() { 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", @@ -295,8 +323,8 @@ impl JellyfinClient { items = fetch_endpoint(&self.client, &url_songs, &headers).await; } - // If the folder items are Audio tracks, sort them by index_number - if items.iter().any(|i| i.item_type == "Audio") { + // If the folder items are Audio tracks, Seasons, or Episodes, sort them by index_number + 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)); } else { sort_media_items_alphabetically(&mut items); @@ -312,10 +340,6 @@ impl JellyfinClient { let guard = self.token.read().await; 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 if item.item_type == "YouTubeVideo" @@ -339,43 +363,13 @@ impl JellyfinClient { }); } - // 2. Series -> resolve next unplayed episode - if item.item_type == "Series" { - if let Ok(headers) = self.get_authed_headers().await { - let next_url = format!("{}/Shows/NextUp?seriesId={}&userId={}&fields=Genres", base_url, item.id, user_id); - 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" + // 2. Series, Season, PhotoAlbum, Folder, MusicArtist, or MusicAlbum -> Open folder in UI + if item.item_type == "Series" + || item.item_type == "Season" + || item.item_type == "PhotoAlbum" || item.item_type == "MusicArtist" || item.item_type == "MusicAlbum" - || (item.is_folder && item.item_type != "Series") + || item.is_folder { return Ok(PlayableTarget::OpenFolder { folder_id: item.id.clone(), diff --git a/jellyfin-kiosk-rs/src/main.rs b/jellyfin-kiosk-rs/src/main.rs index 9b5a59b..23d6e08 100644 --- a/jellyfin-kiosk-rs/src/main.rs +++ b/jellyfin-kiosk-rs/src/main.rs @@ -5,9 +5,11 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod app; mod config; mod eventserver; +mod image_cache; mod jellyfin; mod mqtt; mod player; +mod x11_overlay; mod yatse; mod youtube; @@ -33,8 +35,11 @@ async fn main() -> Result<(), Box> { // 2. Initialize Media Player (MPV Controller) let player = PlayerController::new(config.player.clone()); - // 3. Navigation channel (Yatse Remote -> UI) - let (nav_tx, nav_rx) = mpsc::unbounded_channel(); + // 3. Navigation channel (Yatse Remote -> UI) with immediate egui repaint trigger + let (nav_tx_raw, nav_rx) = mpsc::unbounded_channel(); + let egui_ctx_holder: std::sync::Arc>> = + 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 if config.kodi_rpc.enabled { @@ -103,40 +108,43 @@ async fn main() -> Result<(), Box> { let app_client = client.clone(); let app_player = player.clone(); + let egui_ctx_for_nav = egui_ctx_holder.clone(); // Run eframe app (blocks until window is closed) tokio::task::block_in_place(|| { eframe::run_native( "Jellyfin HTPC Kiosk", native_options, Box::new(move |cc| { + *egui_ctx_for_nav.lock().unwrap() = Some(cc.egui_ctx.clone()); + // Install egui image loaders (http, png, jpg) egui_extras::install_image_loaders(&cc.egui_ctx); + let mut window_id: Option = None; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; if let Ok(handle) = cc.window_handle() { match handle.as_raw() { RawWindowHandle::Xlib(h) => { - let p = app_player.clone(); - let wid = h.window as u64; - tokio::spawn(async move { - p.set_window_id(Some(wid)).await; - }); + window_id = Some(h.window as u64); } RawWindowHandle::Xcb(h) => { - let p = app_player.clone(); - let wid = h.window.get() as u64; - tokio::spawn(async move { - p.set_window_id(Some(wid)).await; - }); + window_id = Some(h.window.get() as u64); } _ => {} } } + 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( app_config, app_client, app_player, nav_rx, + window_id, ))) }), ) diff --git a/jellyfin-kiosk-rs/src/player.rs b/jellyfin-kiosk-rs/src/player.rs index 31ec732..a51ffae 100644 --- a/jellyfin-kiosk-rs/src/player.rs +++ b/jellyfin-kiosk-rs/src/player.rs @@ -54,6 +54,7 @@ pub struct PlayerController { status_tx: tokio::sync::watch::Sender, status_rx: tokio::sync::watch::Receiver, pub window_id: Mutex>, + pub ipc_tx: Mutex>>, } impl PlayerController { @@ -67,6 +68,7 @@ impl PlayerController { status_tx, status_rx, window_id: Mutex::new(None), + ipc_tx: Mutex::new(None), }) } @@ -139,6 +141,13 @@ impl PlayerController { cmd.arg(format!("--input-ipc-server={}", self.config.ipc_socket)); cmd.arg(format!("--hwdec={}", self.config.hwdec)); cmd.arg(format!("--vo={}", self.config.vo)); + cmd.arg("--osd-bar=yes"); + cmd.arg("--osd-level=1"); + cmd.arg("--osd-font-size=28"); + cmd.arg("--osd-on-seek=msg-bar"); + cmd.arg("--input-vo-keyboard=no"); + cmd.arg("--input-cursor=no"); + cmd.arg("--input-cursor-passthrough=yes"); // Configure yt-dlp for YouTube streams let ytdlp_path = if std::path::Path::new("/home/bart/.local/bin/yt-dlp").exists() { @@ -189,12 +198,19 @@ impl PlayerController { } } + // Create persistent IPC command channel + let (ipc_tx, ipc_rx) = tokio::sync::mpsc::unbounded_channel::(); + { + let mut guard = self.ipc_tx.lock().await; + *guard = Some(ipc_tx); + } + // Start background IPC monitoring task let sock_file = self.config.ipc_socket.clone(); let status_arc = self.status.clone(); let status_tx = self.status_tx.clone(); tokio::spawn(async move { - let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx).await; + let _ = monitor_mpv_ipc(sock_file, status_arc, status_tx, ipc_rx).await; }); Ok(()) @@ -212,7 +228,7 @@ impl PlayerController { PlaybackState::Stopped => PlaybackState::Stopped, }; - let _ = self.send_ipc_cmd(&["cycle", "pause"]).await; + let _ = self.send_ipc_cmd(&["osd-msg", "cycle", "pause"]).await; { let mut st = self.status.lock().await; st.state = new_state; @@ -222,6 +238,10 @@ impl PlayerController { pub async fn stop(&self) { let _ = self.send_ipc_cmd(&["stop"]).await; + { + let mut guard = self.ipc_tx.lock().await; + *guard = None; + } let mut proc_guard = self.child_process.lock().await; if let Some(mut child) = proc_guard.take() { let _ = child.kill(); @@ -241,7 +261,17 @@ impl PlayerController { } pub async fn seek(&self, seconds: f64) { - let _ = self.send_ipc_cmd(&["seek", &seconds.to_string(), "relative"]).await; + let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &seconds.to_string(), "relative"]).await; + } + + pub async fn seek_percent(&self, percent: f64) { + let p = percent.clamp(0.0, 100.0); + let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &p.to_string(), "absolute-percent"]).await; + } + + pub async fn seek_time(&self, seconds: f64) { + let s = seconds.max(0.0); + let _ = self.send_ipc_cmd(&["osd-msg-bar", "seek", &s.to_string(), "absolute"]).await; } pub async fn toggle_mute(&self) { @@ -272,7 +302,7 @@ impl PlayerController { let mut st = self.status.lock().await; st.volume = vol; } - let _ = self.send_ipc_cmd(&["set", "volume", &vol.to_string()]).await; + let _ = self.send_ipc_cmd(&["osd-msg-bar", "set", "volume", &vol.to_string()]).await; self.notify_status().await; tracing::info!("Player volume set to {}", vol); } @@ -283,7 +313,7 @@ impl PlayerController { st.volume = (st.volume + 5.0).min(100.0); st.volume }; - let _ = self.send_ipc_cmd(&["add", "volume", "5"]).await; + let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "5"]).await; self.notify_status().await; tracing::info!("Player volume increased to {}", new_vol); } @@ -294,28 +324,49 @@ impl PlayerController { st.volume = (st.volume - 5.0).max(0.0); st.volume }; - let _ = self.send_ipc_cmd(&["add", "volume", "-5"]).await; + let _ = self.send_ipc_cmd(&["osd-msg-bar", "add", "volume", "-5"]).await; self.notify_status().await; tracing::info!("Player volume decreased to {}", new_vol); } pub async fn cycle_subtitle(&self) { let _ = self.send_ipc_cmd(&["cycle", "sub"]).await; + let _ = self.send_ipc_cmd(&[ + "expand-properties", + "show-text", + "💬 Ondertitels: ${current-tracks/sub/title:${current-tracks/sub/lang:${sub:Geen beschikbaar}}}", + "2500", + ]).await; tracing::info!("Cycled subtitle track"); } pub async fn cycle_audio(&self) { let _ = self.send_ipc_cmd(&["cycle", "audio"]).await; + let _ = self.send_ipc_cmd(&[ + "expand-properties", + "show-text", + "🎬 Audio: ${current-tracks/audio/title:${current-tracks/audio/lang:${audio:Standaard}}}", + "2500", + ]).await; tracing::info!("Cycled audio track"); } + pub async fn set_video_visible(&self, visible: bool) { + let arg = if visible { "auto" } else { "no" }; + let _ = self.send_ipc_cmd(&["set", "vid", arg]).await; + tracing::info!("Set MPV video visibility to {}", visible); + } + async fn send_ipc_cmd(&self, args: &[&str]) -> Result<(), Box> { - let mut stream = UnixStream::connect(&self.config.ipc_socket).await?; - let cmd_json = serde_json::json!({ - "command": args - }); - stream.write_all(format!("{}\n", cmd_json).as_bytes()).await?; - Ok(()) + let guard = self.ipc_tx.lock().await; + if let Some(ref tx) = *guard { + let cmd_json = serde_json::json!({ + "command": args + }); + let _ = tx.send(cmd_json); + return Ok(()); + } + Err("MPV IPC not connected".into()) } } @@ -323,6 +374,7 @@ async fn monitor_mpv_ipc( sock_path: String, status: Arc>, status_tx: tokio::sync::watch::Sender, + mut ipc_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> Result<(), Box> { // Wait for socket to become available for _ in 0..30 { @@ -356,7 +408,23 @@ async fn monitor_mpv_ipc( let _ = writer.write_all(format!("{}\n", c).as_bytes()).await; } - while let Ok(Some(line)) = lines.next_line().await { + loop { + tokio::select! { + cmd_opt = ipc_rx.recv() => { + match cmd_opt { + Some(cmd_val) => { + let payload = format!("{}\n", cmd_val); + if let Err(e) = writer.write_all(payload.as_bytes()).await { + tracing::warn!("MPV IPC write error: {}", e); + break; + } + } + None => break, + } + } + line_res = lines.next_line() => { + match line_res { + Ok(Some(line)) => { if let Ok(v) = serde_json::from_str::(&line) { if let Some(event) = v.get("event").and_then(|e| e.as_str()) { if event == "property-change" { @@ -487,6 +555,11 @@ async fn monitor_mpv_ipc( break; } } + } + } + _ => break, + } + } } } diff --git a/jellyfin-kiosk-rs/src/x11_overlay.rs b/jellyfin-kiosk-rs/src/x11_overlay.rs new file mode 100644 index 0000000..ec44ba3 --- /dev/null +++ b/jellyfin-kiosk-rs/src/x11_overlay.rs @@ -0,0 +1,316 @@ +use std::ffi::{c_char, c_int, c_uint, c_void}; +use std::sync::Mutex; + +extern "C" { + fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + fn dlclose(handle: *mut c_void) -> c_int; +} + +const RTLD_LAZY: c_int = 1; + +#[repr(C)] +struct XPoint { + x: i16, + y: i16, +} + +type FnXOpenDisplay = unsafe extern "C" fn(*const c_char) -> *mut c_void; +type FnXCloseDisplay = unsafe extern "C" fn(*mut c_void) -> c_int; +type FnXQueryTree = unsafe extern "C" fn( + *mut c_void, + u64, + *mut u64, + *mut u64, + *mut *mut u64, + *mut c_uint, +) -> c_int; +type FnXFree = unsafe extern "C" fn(*mut c_void) -> c_int; +type FnXMoveResizeWindow = + unsafe extern "C" fn(*mut c_void, u64, c_int, c_int, c_uint, c_uint) -> c_int; +type FnXMapWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; +type FnXUnmapWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; +type FnXFlush = unsafe extern "C" fn(*mut c_void) -> c_int; + +type FnXCreateSimpleWindow = unsafe extern "C" fn( + *mut c_void, + u64, + c_int, + c_int, + c_uint, + c_uint, + c_uint, + u64, + u64, +) -> u64; +type FnXDestroyWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; +type FnXRaiseWindow = unsafe extern "C" fn(*mut c_void, u64) -> c_int; +type FnXCreateGC = unsafe extern "C" fn(*mut c_void, u64, u64, *mut c_void) -> *mut c_void; +type FnXFreeGC = unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int; +type FnXSetForeground = unsafe extern "C" fn(*mut c_void, *mut c_void, u64) -> c_int; +type FnXSetLineAttributes = unsafe extern "C" fn(*mut c_void, *mut c_void, c_uint, c_int, c_int, c_int) -> c_int; +type FnXFillRectangle = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, c_int, c_int, c_uint, c_uint) -> c_int; +type FnXFillPolygon = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, *const XPoint, c_int, c_int, c_int) -> c_int; +type FnXDrawLine = unsafe extern "C" fn(*mut c_void, u64, *mut c_void, c_int, c_int, c_int, c_int) -> c_int; + +pub struct X11Overlay { + _handle: *mut c_void, + xopen_display: FnXOpenDisplay, + xclose_display: FnXCloseDisplay, + xquery_tree: FnXQueryTree, + xfree: FnXFree, + xmove_resize_window: FnXMoveResizeWindow, + xmap_window: FnXMapWindow, + xunmap_window: FnXUnmapWindow, + xflush: FnXFlush, + + xcreate_simple_window: FnXCreateSimpleWindow, + xdestroy_window: FnXDestroyWindow, + xraise_window: FnXRaiseWindow, + xcreate_gc: FnXCreateGC, + xfree_gc: FnXFreeGC, + xset_foreground: FnXSetForeground, + xset_line_attributes: FnXSetLineAttributes, + xfill_rectangle: FnXFillRectangle, + xfill_polygon: FnXFillPolygon, + xdraw_line: FnXDrawLine, + + last_geom: Mutex>, + mute_window: Mutex>, + last_mute_state: Mutex>, +} + +// Implement Send and Sync because all calls open and close their own Display connection +unsafe impl Send for X11Overlay {} +unsafe impl Sync for X11Overlay {} + +impl X11Overlay { + pub fn try_new() -> Option { + unsafe { + let handle = dlopen(b"libX11.so.6\0".as_ptr() as *const _, RTLD_LAZY); + if handle.is_null() { + return None; + } + + macro_rules! sym { + ($name:expr, $ty:ty) => {{ + let s = dlsym(handle, $name.as_ptr() as *const _); + if s.is_null() { + dlclose(handle); + return None; + } + std::mem::transmute::<*mut c_void, $ty>(s) + }}; + } + + let xopen_display = sym!(b"XOpenDisplay\0", FnXOpenDisplay); + let xclose_display = sym!(b"XCloseDisplay\0", FnXCloseDisplay); + let xquery_tree = sym!(b"XQueryTree\0", FnXQueryTree); + let xfree = sym!(b"XFree\0", FnXFree); + let xmove_resize_window = sym!(b"XMoveResizeWindow\0", FnXMoveResizeWindow); + let xmap_window = sym!(b"XMapWindow\0", FnXMapWindow); + let xunmap_window = sym!(b"XUnmapWindow\0", FnXUnmapWindow); + let xflush = sym!(b"XFlush\0", FnXFlush); + + let xcreate_simple_window = sym!(b"XCreateSimpleWindow\0", FnXCreateSimpleWindow); + let xdestroy_window = sym!(b"XDestroyWindow\0", FnXDestroyWindow); + let xraise_window = sym!(b"XRaiseWindow\0", FnXRaiseWindow); + let xcreate_gc = sym!(b"XCreateGC\0", FnXCreateGC); + let xfree_gc = sym!(b"XFreeGC\0", FnXFreeGC); + let xset_foreground = sym!(b"XSetForeground\0", FnXSetForeground); + let xset_line_attributes = sym!(b"XSetLineAttributes\0", FnXSetLineAttributes); + let xfill_rectangle = sym!(b"XFillRectangle\0", FnXFillRectangle); + let xfill_polygon = sym!(b"XFillPolygon\0", FnXFillPolygon); + let xdraw_line = sym!(b"XDrawLine\0", FnXDrawLine); + + Some(Self { + _handle: handle, + xopen_display, + xclose_display, + xquery_tree, + xfree, + xmove_resize_window, + xmap_window, + xunmap_window, + xflush, + xcreate_simple_window, + xdestroy_window, + xraise_window, + xcreate_gc, + xfree_gc, + xset_foreground, + xset_line_attributes, + xfill_rectangle, + xfill_polygon, + xdraw_line, + last_geom: Mutex::new(None), + mute_window: Mutex::new(None), + last_mute_state: Mutex::new(None), + }) + } + } + + pub fn reset_cache(&self) { + if let Ok(mut g) = self.last_geom.lock() { + *g = None; + } + if let Ok(mut m) = self.last_mute_state.lock() { + *m = None; + } + } + + pub fn set_geometry( + &self, + parent_wid: u64, + x: i32, + y: i32, + width: u32, + height: u32, + visible: bool, + ) { + let target = (x, y, width, height, visible); + if let Ok(mut g) = self.last_geom.lock() { + if *g == Some(target) { + return; + } + *g = Some(target); + } + + unsafe { + let display = (self.xopen_display)(std::ptr::null()); + if display.is_null() { + return; + } + + let mut root = 0u64; + let mut parent = 0u64; + let mut children: *mut u64 = std::ptr::null_mut(); + let mut nchildren: c_uint = 0; + + let status = (self.xquery_tree)( + display, + parent_wid, + &mut root, + &mut parent, + &mut children, + &mut nchildren, + ); + + let mute_win = self.mute_window.lock().ok().and_then(|g| *g); + + if status != 0 && !children.is_null() && nchildren > 0 { + for i in 0..(nchildren as usize) { + let child = *children.add(i); + // Don't modify the mute badge window here + if Some(child) == mute_win { + continue; + } + if visible { + (self.xmap_window)(display, child); + (self.xmove_resize_window)(display, child, x, y, width, height); + } else { + (self.xunmap_window)(display, child); + } + } + (self.xfree)(children as *mut c_void); + (self.xflush)(display); + } + + (self.xclose_display)(display); + } + } + + pub fn sync_mute_badge(&self, parent_wid: u64, screen_w: u32, is_muted: bool) { + if let Ok(mut st) = self.last_mute_state.lock() { + if *st == Some((is_muted, screen_w)) { + return; + } + *st = Some((is_muted, screen_w)); + } + + unsafe { + let display = (self.xopen_display)(std::ptr::null()); + if display.is_null() { + return; + } + + let mut mute_win = { + let g = self.mute_window.lock().unwrap(); + *g + }; + + if is_muted { + let x = (screen_w as i32) - 58; + let y = 16i32; + let w = 36u32; + let h = 24u32; + + if mute_win.is_none() { + let win = (self.xcreate_simple_window)( + display, + parent_wid, + x, + y, + w, + h, + 1, + 0xFF5A5A, + 0xD71E1E, + ); + let mut g = self.mute_window.lock().unwrap(); + *g = Some(win); + mute_win = Some(win); + } + + if let Some(win) = mute_win { + (self.xmove_resize_window)(display, win, x, y, w, h); + (self.xmap_window)(display, win); + (self.xraise_window)(display, win); + + // Draw speaker icon & mute slash + let gc = (self.xcreate_gc)(display, win, 0, std::ptr::null_mut()); + if !gc.is_null() { + (self.xset_foreground)(display, gc, 0xFFFFFF); + // Speaker body + (self.xfill_rectangle)(display, win, gc, 7, 8, 5, 8); + // Speaker cone + let points = [ + XPoint { x: 12, y: 8 }, + XPoint { x: 18, y: 4 }, + XPoint { x: 18, y: 20 }, + XPoint { x: 12, y: 16 }, + ]; + (self.xfill_polygon)(display, win, gc, points.as_ptr(), 4, 1, 0); + // Mute slash + (self.xset_line_attributes)(display, gc, 2, 0, 0, 0); + (self.xdraw_line)(display, win, gc, 22, 7, 28, 17); + (self.xdraw_line)(display, win, gc, 22, 17, 28, 7); + (self.xfree_gc)(display, gc); + } + } + } else if let Some(win) = mute_win { + (self.xunmap_window)(display, win); + } + + (self.xflush)(display); + (self.xclose_display)(display); + } + } +} + +impl Drop for X11Overlay { + fn drop(&mut self) { + if let Ok(mut g) = self.mute_window.lock() { + if let Some(win) = g.take() { + unsafe { + let display = (self.xopen_display)(std::ptr::null()); + if !display.is_null() { + (self.xdestroy_window)(display, win); + (self.xflush)(display); + (self.xclose_display)(display); + } + } + } + } + } +} diff --git a/jellyfin-kiosk-rs/src/yatse.rs b/jellyfin-kiosk-rs/src/yatse.rs index 7ecdb7a..d712e08 100644 --- a/jellyfin-kiosk-rs/src/yatse.rs +++ b/jellyfin-kiosk-rs/src/yatse.rs @@ -21,17 +21,51 @@ pub enum RemoteNavAction { Back, ToggleOSD, CycleSubtitle, + CycleAudio, + ToggleMute, TogglePlaybackView, ActivateSection(usize), VolumeUp, VolumeDown, + PlayPause, Stop, } +#[derive(Clone)] +pub struct RemoteNavSender { + tx: UnboundedSender, + ctx: Arc>>, +} + +impl RemoteNavSender { + pub fn new(tx: UnboundedSender, ctx: Arc>>) -> Self { + Self { tx, ctx } + } + + pub fn send(&self, action: RemoteNavAction) -> Result<(), tokio::sync::mpsc::error::SendError> { + let res = self.tx.send(action); + if let Ok(guard) = self.ctx.lock() { + if let Some(ref c) = *guard { + c.request_repaint(); + } + } + res + } +} + +impl From> for RemoteNavSender { + fn from(tx: UnboundedSender) -> Self { + Self { + tx, + ctx: Arc::new(std::sync::Mutex::new(None)), + } + } +} + #[derive(Clone)] pub struct YatseServerState { pub player: Arc, - pub nav_tx: UnboundedSender, + pub nav_tx: RemoteNavSender, } #[derive(Debug, Deserialize)] @@ -142,17 +176,26 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { } "Application.SetVolume" => { let vol_param = req.params.as_ref() - .and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first()))) - .and_then(|v| v.as_f64()); + .and_then(|p| p.get("volume").or_else(|| p.as_array().and_then(|a| a.first()))); - let new_vol = match vol_param { - Some(v) => { - state.player.set_volume(v).await; - v.clamp(0.0, 100.0) as i64 - } - None => { + let new_vol = if let Some(v_str) = vol_param.and_then(|v| v.as_str()) { + if v_str == "increment" { + state.player.volume_up().await; + let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); + state.player.status.lock().await.volume as i64 + } else if v_str == "decrement" { + state.player.volume_down().await; + let _ = state.nav_tx.send(RemoteNavAction::VolumeDown); + state.player.status.lock().await.volume as i64 + } else { state.player.status.lock().await.volume as i64 } + } else if let Some(v) = vol_param.and_then(|v| v.as_f64()) { + state.player.set_volume(v).await; + let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); + v.clamp(0.0, 100.0) as i64 + } else { + state.player.status.lock().await.volume as i64 }; json!({ "jsonrpc": "2.0", @@ -163,9 +206,11 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { "Player.GetActivePlayers" => { let st = state.player.status.lock().await; if st.state != PlaybackState::Stopped { + let pid = if st.media_type == "audio" { 0 } else { 1 }; + let ptype = if st.media_type == "audio" { "audio" } else { "video" }; json!({ "jsonrpc": "2.0", - "result": [{"playerid": 1, "playertype": "internal", "type": "video"}], + "result": [{"playerid": pid, "playertype": "internal", "type": ptype}], "id": id }) } else { @@ -181,28 +226,55 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { let time = seconds_to_kodi_time(st.time_pos); let totaltime = seconds_to_kodi_time(st.duration); let speed = if st.state == PlaybackState::Playing { 1 } else { 0 }; + let ptype = if st.media_type == "audio" { "audio" } else { "video" }; json!({ "jsonrpc": "2.0", "result": { "canseek": true, + "canchangespeed": true, + "canmove": false, + "canzoom": false, + "canrotate": false, "speed": speed, "time": time, "totaltime": totaltime, - "percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 } + "percentage": if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { 0.0 }, + "type": ptype, + "live": false, + "subtitleenabled": true, + "subtitles": [ + {"index": 0, "name": "Ondertitel 1", "language": ""}, + {"index": 1, "name": "Ondertitel 2", "language": ""} + ], + "currentsubtitle": {"index": 0, "name": "Ondertitel", "language": ""}, + "audiostreams": [ + {"index": 0, "name": "Audiostroom 1", "language": ""}, + {"index": 1, "name": "Audiostroom 2", "language": ""} + ], + "currentaudiostream": {"index": 0, "name": "Audiostroom", "language": ""} }, "id": id }) } "Player.GetItem" => { let st = state.player.status.lock().await; + let title = if !st.stream_title.is_empty() { + &st.stream_title + } else if !st.title.is_empty() { + &st.title + } else { + "Media" + }; + let itype = if st.media_type == "audio" { "song" } else { "movie" }; json!({ "jsonrpc": "2.0", "result": { "item": { - "label": st.title, - "title": st.title, - "type": "movie" + "label": title, + "title": title, + "artist": if !st.artist.is_empty() { vec![st.artist.clone()] } else { Vec::new() }, + "type": itype } }, "id": id @@ -220,6 +292,7 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { } "Player.Stop" => { state.player.stop().await; + let _ = state.nav_tx.send(RemoteNavAction::Stop); json!({ "jsonrpc": "2.0", "result": "OK", @@ -227,17 +300,87 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { }) } "Player.Seek" => { - if let Some(params) = req.params { - if let Some(sec) = params.get("value").and_then(|v| v.as_f64()) { - state.player.seek(sec).await; + let mut result_pct = 0.0; + if let Some(ref params) = req.params { + let val = params.get("value").or_else(|| params.as_array().and_then(|a| a.get(1))); + if let Some(v) = val { + if let Some(pct) = v.get("percentage").and_then(|p| p.as_f64()) { + state.player.seek_percent(pct).await; + result_pct = pct; + } else if let Some(time_obj) = v.get("time") { + let h = time_obj.get("hours").and_then(|x| x.as_f64()).unwrap_or(0.0); + let m = time_obj.get("minutes").and_then(|x| x.as_f64()).unwrap_or(0.0); + let s = time_obj.get("seconds").and_then(|x| x.as_f64()).unwrap_or(0.0); + let total_secs = h * 3600.0 + m * 60.0 + s; + state.player.seek_time(total_secs).await; + } else if let Some(step) = v.as_str() { + match step.to_lowercase().as_str() { + "smallforward" => { state.player.seek(10.0).await; } + "smallbackward" => { state.player.seek(-10.0).await; } + "bigforward" => { state.player.seek(60.0).await; } + "bigbackward" => { state.player.seek(-60.0).await; } + _ => {} + } + } else if let Some(n) = v.as_f64() { + if n >= 0.0 && n <= 100.0 { + state.player.seek_percent(n).await; + result_pct = n; + } else { + state.player.seek(n).await; + } + } } } + let st = state.player.status.lock().await; + let time = seconds_to_kodi_time(st.time_pos); + let totaltime = seconds_to_kodi_time(st.duration); + let pct = if st.duration > 0.0 { (st.time_pos / st.duration) * 100.0 } else { result_pct }; json!({ "jsonrpc": "2.0", - "result": {"percentage": 50.0}, + "result": { + "percentage": pct, + "time": time, + "totaltime": totaltime + }, "id": id }) } + "Player.SetSubtitle" => { + state.player.cycle_subtitle().await; + let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Player.SetAudioStream" => { + state.player.cycle_audio().await; + json!({"jsonrpc": "2.0", "result": "OK", "id": id}) + } + "Player.SetSpeed" => { + if let Some(ref params) = req.params { + let speed_val = params.get("speed").or_else(|| params.as_array().and_then(|a| a.get(1))); + if let Some(s) = speed_val.and_then(|v| v.as_i64()) { + if s == 0 { + let st = state.player.status.lock().await; + if st.state == PlaybackState::Playing { + drop(st); + state.player.play_pause().await; + } + } else if s == 1 { + let st = state.player.status.lock().await; + if st.state == PlaybackState::Paused { + drop(st); + state.player.play_pause().await; + } + } + } else if let Some(s_str) = speed_val.and_then(|v| v.as_str()) { + if s_str == "increment" || s_str == "decrement" { + state.player.play_pause().await; + } + } + } + let st = state.player.status.lock().await; + let speed = if st.state == PlaybackState::Playing { 1 } else { 0 }; + json!({"jsonrpc": "2.0", "result": {"speed": speed}, "id": id}) + } "Player.Open" => { if let Some(params) = req.params { if let Some(item) = params.get("item") { @@ -285,50 +428,75 @@ async fn dispatch_rpc(state: &YatseServerState, req: JsonRpcRequest) -> Value { json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "Input.ExecuteAction" => { - let action = req.params.as_ref() + let raw_action = req.params.as_ref() .and_then(|p| p.get("action").or_else(|| p.as_array().and_then(|a| a.first()))) .and_then(|a| a.as_str()) - .unwrap_or("") - .to_lowercase(); + .unwrap_or(""); + let action = if raw_action.to_lowercase().starts_with("action(") && raw_action.ends_with(')') { + raw_action[7..raw_action.len() - 1].trim().to_lowercase() + } else { + raw_action.to_lowercase() + }; match action.as_str() { - "up" | "cursorup" => { let _ = state.nav_tx.send(RemoteNavAction::Up); } - "down" | "cursordown" => { let _ = state.nav_tx.send(RemoteNavAction::Down); } - "left" | "cursorleft" => { let _ = state.nav_tx.send(RemoteNavAction::Left); } - "right" | "cursorright" => { let _ = state.nav_tx.send(RemoteNavAction::Right); } - "select" | "enter" => { let _ = state.nav_tx.send(RemoteNavAction::Select); } - "back" | "previousmenu" | "parentdir" | "close" => { let _ = state.nav_tx.send(RemoteNavAction::Back); } - "togglefullscreen" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); } - "osd" | "nextsubtitle" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); } - "title" | "menu" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); } - "volumeup" => { + "up" | "cursorup" | "3" => { let _ = state.nav_tx.send(RemoteNavAction::Up); } + "down" | "cursordown" | "4" => { let _ = state.nav_tx.send(RemoteNavAction::Down); } + "left" | "cursorleft" | "1" | "stepback" | "smallstepback" => { let _ = state.nav_tx.send(RemoteNavAction::Left); } + "right" | "cursorright" | "2" | "stepforward" | "smallstepforward" => { let _ = state.nav_tx.send(RemoteNavAction::Right); } + "bigstepforward" => { + let p = state.player.clone(); + tokio::spawn(async move { p.seek(60.0).await; }); + } + "bigstepback" => { + let p = state.player.clone(); + tokio::spawn(async move { p.seek(-60.0).await; }); + } + "select" | "enter" | "7" => { let _ = state.nav_tx.send(RemoteNavAction::Select); } + "back" | "previousmenu" | "parentdir" | "close" | "9" | "10" | "escape" => { let _ = state.nav_tx.send(RemoteNavAction::Back); } + "togglefullscreen" | "12" => { let _ = state.nav_tx.send(RemoteNavAction::ToggleOSD); } + "osd" | "nextsubtitle" | "114" | "122" => { let _ = state.nav_tx.send(RemoteNavAction::CycleSubtitle); } + "audionextlanguage" | "nextaudio" => { + let p = state.player.clone(); + tokio::spawn(async move { p.cycle_audio().await; }); + } + "title" | "menu" | "11" => { let _ = state.nav_tx.send(RemoteNavAction::TogglePlaybackView); } + "volumeup" | "88" => { let _ = state.nav_tx.send(RemoteNavAction::VolumeUp); let p = state.player.clone(); tokio::spawn(async move { p.volume_up().await; }); } - "volumedown" => { + "volumedown" | "89" => { let _ = state.nav_tx.send(RemoteNavAction::VolumeDown); let p = state.player.clone(); tokio::spawn(async move { p.volume_down().await; }); } - "playpause" | "pause" | "play" => { state.player.play_pause().await; } - "stop" => { + "playpause" | "pause" | "play" | "15" => { state.player.play_pause().await; } + "stop" | "14" => { let _ = state.nav_tx.send(RemoteNavAction::Stop); state.player.stop().await; } + "mute" | "togglemute" | "13" => { + let p = state.player.clone(); + tokio::spawn(async move { p.toggle_mute().await; }); + } _ => {} } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) } "GUI.ActivateWindow" => { if let Some(params) = req.params { - let window = params.get("window").and_then(|w| w.as_str()).unwrap_or("").to_lowercase(); - match window.as_str() { - "videos" | "video" | "movies" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); } - "tvshows" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); } - "music" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); } - "pictures" | "photos" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); } - "home" => { let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0)); } - _ => {} + let p_str = params.to_string().to_lowercase(); + if p_str.contains("tvshow") || p_str.contains("series") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(3)); + } else if p_str.contains("movietitles") || p_str.contains("movie") || p_str.contains("video") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(2)); + } else if p_str.contains("music") || p_str.contains("song") || p_str.contains("artist") || p_str.contains("album") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(4)); + } else if p_str.contains("picture") || p_str.contains("photo") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(5)); + } else if p_str.contains("livetv") || p_str.contains("channel") || p_str.contains("tv") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(1)); + } else if p_str.contains("home") { + let _ = state.nav_tx.send(RemoteNavAction::ActivateSection(0)); } } json!({"jsonrpc": "2.0", "result": "OK", "id": id}) diff --git a/jellyfin-kiosk-rs/src/youtube.rs b/jellyfin-kiosk-rs/src/youtube.rs index 1dd27d2..503f20a 100644 --- a/jellyfin-kiosk-rs/src/youtube.rs +++ b/jellyfin-kiosk-rs/src/youtube.rs @@ -202,6 +202,8 @@ impl YouTubeClient { 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 = [ ("client_id", self.config.client_id.as_str()), ("scope", "https://www.googleapis.com/auth/youtube.readonly"), @@ -213,18 +215,34 @@ impl YouTubeClient { .form(¶ms) .send() .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() { let status = res.status(); 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)); } let resp: DeviceCodeResponse = res .json() .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) } diff --git a/jellyfin-kiosk-rs/youtube_token.json b/jellyfin-kiosk-rs/youtube_token.json new file mode 100644 index 0000000..436e29d --- /dev/null +++ b/jellyfin-kiosk-rs/youtube_token.json @@ -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" +} \ No newline at end of file diff --git a/remote-build.sh b/remote-build.sh new file mode 100755 index 0000000..0a79767 --- /dev/null +++ b/remote-build.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -eo pipefail + +REMOTE_HOST="${REMOTE_HOST:-bart-workstation.lan}" +REMOTE_DIR="${REMOTE_DIR:-~/builds/jellyfin-client}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLIENT_DIR="${SCRIPT_DIR}" +KIOSK_DIR="${SCRIPT_DIR}/jellyfin-kiosk-rs" + +COMMAND="${1:-release}" +shift || true + +case "${COMMAND}" in + check) + CARGO_CMD="cargo check $*" + FETCH_BIN=false + ;; + build) + CARGO_CMD="cargo build $*" + FETCH_BIN=false + ;; + release) + CARGO_CMD="cargo build --release $*" + FETCH_BIN=true + ;; + test) + CARGO_CMD="cargo test $*" + FETCH_BIN=false + ;; + cargo) + CARGO_CMD="cargo $*" + FETCH_BIN=false + ;; + *) + CARGO_CMD="cargo ${COMMAND} $*" + FETCH_BIN=false + ;; +esac + +echo "==> 📡 Syncing source code to ${REMOTE_HOST}:${REMOTE_DIR}..." +rsync -az --delete \ + --exclude 'target' \ + --exclude '.git' \ + --exclude '.venv' \ + --exclude '__pycache__' \ + --exclude '.pytest_cache' \ + "${CLIENT_DIR}/" "${REMOTE_HOST}:${REMOTE_DIR}/" + +echo "==> 🚀 Running on ${REMOTE_HOST}: ${CARGO_CMD}..." +ssh -t "${REMOTE_HOST}" "cd ${REMOTE_DIR}/jellyfin-kiosk-rs && ${CARGO_CMD}" + +if [ "${FETCH_BIN}" = true ]; then + echo "==> 📥 Downloading compiled release binary to laptop..." + mkdir -p "${KIOSK_DIR}/target/release" + rsync -avz "${REMOTE_HOST}:${REMOTE_DIR}/jellyfin-kiosk-rs/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/target/release/" + cp "${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" "${KIOSK_DIR}/jellyfin-kiosk" + echo "==> ✅ Klaar! Binary beschikbaar in:" + echo " - ${KIOSK_DIR}/target/release/jellyfin-kiosk-rs" + echo " - ${KIOSK_DIR}/jellyfin-kiosk" +fi