diff --git a/README.md b/README.md index 1bcfc06..584d2b1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# JellyTau +

+ JellyTau logo
+ JellyTau +

A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000..b789169 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..2dc5c94 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..468cf0d 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..9329f29 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..d016337 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..e590cdf 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..e085d08 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..7e5f8ff 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..5411cac 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..face76f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..29089fb 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..5978d75 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..4d4aada 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..d32765e 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..3f0e1cb 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..01e6e50 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..69b38f3 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..a8f5866 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..b5edac4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..b891bb2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..b891bb2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..20cc9a4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..8ee1157 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..504eb2f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..504eb2f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..868f5fe Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..b891bb2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..ee7c587 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..ee7c587 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..625683a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..949df8e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..625683a Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..e667594 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..8817bb0 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..1de1203 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..6043e59 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands/playback_mode.rs b/src-tauri/src/commands/playback_mode.rs index 3988ca1..d89f99e 100644 --- a/src-tauri/src/commands/playback_mode.rs +++ b/src-tauri/src/commands/playback_mode.rs @@ -51,6 +51,22 @@ pub async fn playback_mode_transfer_to_remote( manager.0.transfer_to_remote(session_id, position).await } +/// Set the transferring flag on the playback mode manager. +/// +/// Used by the frontend remote->local flow to mark the whole two-step sequence +/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of +/// casting back to the remote session it's leaving. Always pair `true` with a +/// later `false` (including on error) so the flag can't stick. +#[tauri::command] +#[specta::specta] +pub async fn playback_mode_set_transferring( + manager: State<'_, PlaybackModeManagerWrapper>, + transferring: bool, +) -> Result<(), String> { + manager.0.set_transferring(transferring); + Ok(()) +} + /// Transfer playback from remote session back to local device /// /// Parameters: diff --git a/src-tauri/src/commands/player/remote.rs b/src-tauri/src/commands/player/remote.rs index 687257a..b82e446 100644 --- a/src-tauri/src/commands/player/remote.rs +++ b/src-tauri/src/commands/player/remote.rs @@ -6,6 +6,7 @@ use tauri::State; use super::PlayerStateWrapper; +use crate::jellyfin::client::LmsSyncGroup; /// Play items on a remote Jellyfin session (casting) #[tauri::command] @@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute( Err("Jellyfin client not configured".to_string()) } } + +// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) -------------- +// +// The frontend addresses LMS players by MAC address, which it derives from a +// session's device id (`lms-{mac}`). These commands forward to the JellyLMS +// plugin REST API via the configured JellyfinClient. + +/// List current LMS sync groups. +#[tauri::command] +#[specta::specta] +pub async fn lms_get_sync_groups( + player: State<'_, PlayerStateWrapper>, +) -> Result, String> { + let client_opt = { + let controller = player.0.lock().await; + controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone() + }; + + if let Some(client) = client_opt { + client.lms_get_sync_groups().await + } else { + Err("Jellyfin client not configured".to_string()) + } +} + +/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the +/// `slave_macs` zones join it in sync. +#[tauri::command] +#[specta::specta] +pub async fn lms_create_sync_group( + player: State<'_, PlayerStateWrapper>, + master_mac: String, + slave_macs: Vec, +) -> Result<(), String> { + log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs); + + let client_opt = { + let controller = player.0.lock().await; + controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone() + }; + + if let Some(client) = client_opt { + client.lms_create_sync_group(&master_mac, slave_macs).await + } else { + Err("Jellyfin client not configured".to_string()) + } +} + +/// Remove a single LMS zone from its sync group (decouple one player). +#[tauri::command] +#[specta::specta] +pub async fn lms_unsync_player( + player: State<'_, PlayerStateWrapper>, + mac: String, +) -> Result<(), String> { + log::info!("[LmsSync] Decoupling zone {}", mac); + + let client_opt = { + let controller = player.0.lock().await; + controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone() + }; + + if let Some(client) = client_opt { + client.lms_unsync_player(&mac).await + } else { + Err("Jellyfin client not configured".to_string()) + } +} + +/// Dissolve an entire LMS sync group, identified by its master's MAC. +#[tauri::command] +#[specta::specta] +pub async fn lms_dissolve_sync_group( + player: State<'_, PlayerStateWrapper>, + master_mac: String, +) -> Result<(), String> { + log::info!("[LmsSync] Dissolving group with master {}", master_mac); + + let client_opt = { + let controller = player.0.lock().await; + controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone() + }; + + if let Some(client) = client_opt { + client.lms_dissolve_sync_group(&master_mac).await + } else { + Err("Jellyfin client not configured".to_string()) + } +} diff --git a/src-tauri/src/commands/repository.rs b/src-tauri/src/commands/repository.rs index 8557c49..5b0e6e7 100644 --- a/src-tauri/src/commands/repository.rs +++ b/src-tauri/src/commands/repository.rs @@ -410,6 +410,49 @@ pub async fn repository_get_audio_stream_url( .map_err(|e| format!("{:?}", e)) } +/// Get Live TV channels (broadcast / IPTV) for browsing +#[tauri::command] +#[specta::specta] +pub async fn repository_get_live_tv_channels( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result, String> { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .get_live_tv_channels() + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Get the root list of plugin "Channels" +#[tauri::command] +#[specta::specta] +pub async fn repository_get_channels( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .get_channels() + .await + .map_err(|e| format!("{:?}", e)) +} + +/// Open a live stream for a Live TV channel / live item +#[tauri::command] +#[specta::specta] +pub async fn repository_open_live_stream( + manager: State<'_, RepositoryManagerWrapper>, + handle: String, + item_id: String, +) -> Result { + let repo = manager.0.get(&handle).ok_or("Repository not found")?; + repo.as_ref() + .open_live_stream(&item_id) + .await + .map_err(|e| format!("{:?}", e)) +} + /// Report playback start #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/jellyfin/client.rs b/src-tauri/src/jellyfin/client.rs index 59597c6..d6f062c 100644 --- a/src-tauri/src/jellyfin/client.rs +++ b/src-tauri/src/jellyfin/client.rs @@ -384,6 +384,82 @@ impl JellyfinClient { let sessions = self.get_sessions().await?; Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id))) } + + // --- JellyLMS multi-room sync groups ----------------------------------- + // + // The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS + // players ("zones") into synchronized multi-room sync groups. Players are + // addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by + // stripping the `lms-` prefix off the session's device id (see + // LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player + // with deviceId = "lms-{MacAddress}"). + + /// List current LMS sync groups. + pub async fn lms_get_sync_groups(&self) -> Result, String> { + self.get("/JellyLms/SyncGroups").await + } + + /// Fuse LMS zones: create a sync group with `master_mac` as the sync master + /// and `slave_macs` joining it. The master keeps playing; slaves follow. + pub async fn lms_create_sync_group( + &self, + master_mac: &str, + slave_macs: Vec, + ) -> Result<(), String> { + let payload = serde_json::json!({ + "MasterMac": master_mac, + "SlaveMacs": slave_macs, + }); + self.post("/JellyLms/SyncGroups", &payload).await + } + + /// Remove a single LMS player from whatever sync group it's in. + pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> { + self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await + } + + /// Dissolve an entire LMS sync group, identified by its master's MAC. + pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> { + self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await + } + + /// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints). + async fn delete(&self, endpoint: &str) -> Result<(), String> { + let url = format!("{}{}", self.config.server_url, endpoint); + + log::debug!("[JellyfinClient] DELETE {}", endpoint); + + let response = self.http_client + .delete(&url) + .header("X-Emby-Authorization", self.get_auth_header()) + .send() + .await + .map_err(|e| format!("Network request failed: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text)); + } + Ok(()) + } +} + +/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`. +/// +/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves +/// follow it in lockstep. +#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LmsSyncGroup { + #[serde(alias = "MasterMac")] + pub master_mac: String, + #[serde(default, alias = "MasterName")] + pub master_name: String, + #[serde(default, alias = "SlaveMacs")] + pub slave_macs: Vec, + #[serde(default, alias = "SlaveNames")] + pub slave_names: Vec, } /// Default value for supports_remote_control when missing from API diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5e405e..a147da7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,11 +52,13 @@ use commands::{ // Remote session control commands remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_session_toggle_mute, + // LMS multi-room sync group commands + lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group, // Session polling commands sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper, // Playback mode commands playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, - playback_mode_transfer_to_remote, playback_mode_transfer_to_local, + playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring, playback_mode_get_remote_status, // Playback reporting commands playback_reporter_init, playback_reporter_destroy, @@ -99,6 +101,7 @@ use commands::{ repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, + repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_person, repository_get_items_by_person, repository_get_similar_items, @@ -417,6 +420,11 @@ fn specta_builder() -> Builder { remote_session_seek, remote_session_set_volume, remote_session_toggle_mute, + // LMS multi-room sync group commands + lms_get_sync_groups, + lms_create_sync_group, + lms_unsync_player, + lms_dissolve_sync_group, // Session polling commands sessions_set_polling_hint, sessions_poll_now, @@ -427,6 +435,7 @@ fn specta_builder() -> Builder { playback_mode_transfer_to_remote, playback_mode_get_remote_status, playback_mode_transfer_to_local, + playback_mode_set_transferring, // Playback reporting commands playback_reporter_init, playback_reporter_destroy, @@ -564,6 +573,9 @@ fn specta_builder() -> Builder { repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, + repository_get_live_tv_channels, + repository_get_channels, + repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, diff --git a/src-tauri/src/playback_mode/mod.rs b/src-tauri/src/playback_mode/mod.rs index 056b250..ec77872 100644 --- a/src-tauri/src/playback_mode/mod.rs +++ b/src-tauri/src/playback_mode/mod.rs @@ -81,6 +81,19 @@ impl PlaybackModeManager { self.is_transferring.load(Ordering::Relaxed) } + /// Set the transferring flag directly. + /// + /// The remote->local transfer is driven from the frontend in two steps + /// (`player_play_tracks` to start local playback, then + /// `playback_mode_transfer_to_local` to stop the remote). The first step's + /// routing depends on this flag: while it's set, `player_play_tracks` plays + /// locally instead of casting back to the remote session. The frontend must + /// raise the flag *before* that first call and lower it when the sequence is + /// done (or aborts), so it can't be left stuck on. + pub fn set_transferring(&self, transferring: bool) { + self.is_transferring.store(transferring, Ordering::Relaxed); + } + /// Send volume command to remote session /// Commands: "SetVolume", "VolumeUp", "VolumeDown" #[allow(dead_code)] // Called from Android JNI callback diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index fd72aba..4c40177 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -415,6 +415,21 @@ impl MediaRepository for HybridRepository { self.online.get_audio_stream_url(item_id).await } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV requires server communication - delegate to online repository + self.online.get_live_tv_channels().await + } + + async fn get_channels(&self) -> Result { + // Plugin channels require server communication - delegate to online repository + self.online.get_channels().await + } + + async fn open_live_stream(&self, item_id: &str) -> Result { + // Opening a live stream requires server communication - delegate to online + self.online.open_live_stream(item_id).await + } + async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> { // Playback reporting goes directly to server self.online.report_playback_start(item_id, position_ticks).await @@ -722,6 +737,18 @@ mod tests { unimplemented!() } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + unimplemented!() + } + + async fn get_channels(&self) -> Result { + unimplemented!() + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + unimplemented!() + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { unimplemented!() } @@ -887,6 +914,18 @@ mod tests { unimplemented!() } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + unimplemented!() + } + + async fn get_channels(&self) -> Result { + unimplemented!() + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + unimplemented!() + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { unimplemented!() } @@ -976,6 +1015,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Movie".to_string(), + is_folder: false, server_id: "test-server".to_string(), parent_id: Some("parent-123".to_string()), library_id: Some("library-456".to_string()), diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index 94ac332..1edbbc9 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -117,6 +117,19 @@ pub trait MediaRepository: Send + Sync { /// @req: JA-007 - Get playback info and stream URL async fn get_audio_stream_url(&self, item_id: &str) -> Result; + /// Get Live TV channels (broadcast / IPTV) for browsing. + async fn get_live_tv_channels(&self) -> Result, RepoError>; + + /// Get the root list of plugin "Channels" (Jellyfin Channels feature). + /// Drill-down into a channel reuses `get_items(channel_id, ...)`. + async fn get_channels(&self) -> Result; + + /// Open a live stream (Live TV channel or live channel item) for playback. + /// + /// Returns the server transcoding URL plus identifiers needed to manage the + /// stream. Required before a live channel can be played over HLS. + async fn open_live_stream(&self, item_id: &str) -> Result; + /// Report playback start /// /// @req: UR-025 - Sync watch history and progress back to Jellyfin diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 360369c..0b97119 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -29,6 +29,7 @@ impl OfflineRepository { id: item.id.clone(), name: item.name, item_type: item.item_type, + is_folder: item.is_folder, server_id: item.server_id, parent_id: item.parent_id, library_id: item.library_id, @@ -92,6 +93,7 @@ struct CachedItem { id: String, name: String, item_type: String, + is_folder: bool, server_id: String, parent_id: Option, library_id: Option, @@ -143,6 +145,8 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result { season_id: row.get(20)?, season_name: row.get(21)?, parent_index_number: row.get(22)?, + // Appended as the final column in every SELECT that maps through this fn. + is_folder: row.get::<_, Option>(23)?.unwrap_or(0) != 0, }) } @@ -230,7 +234,7 @@ impl OfflineRepository { let query = Query::with_params( "INSERT OR REPLACE INTO items ( id, server_id, library_id, parent_id, - name, item_type, overview, + name, item_type, is_folder, overview, genres, series_id, series_name, season_id, season_name, index_number, parent_index_number, album_id, album_name, album_artist, artists, @@ -240,14 +244,14 @@ impl OfflineRepository { synced_at ) VALUES ( ?1, ?2, ?3, ?4, - ?5, ?6, ?7, - ?8, ?9, ?10, - ?11, ?12, ?13, ?14, - ?15, ?16, ?17, ?18, - ?19, ?20, - ?21, ?22, - ?23, ?24, - ?25 + ?5, ?6, ?7, ?8, + ?9, ?10, ?11, + ?12, ?13, ?14, ?15, + ?16, ?17, ?18, ?19, + ?20, ?21, + ?22, ?23, + ?24, ?25, + ?26 )", vec![ QueryParam::String(item.id.clone()), @@ -261,6 +265,7 @@ impl OfflineRepository { }, QueryParam::String(item.name.clone()), QueryParam::String(item.item_type.clone()), + QueryParam::Int(if item.is_folder { 1 } else { 0 }), match &item.overview { Some(o) => QueryParam::String(o.clone()), None => QueryParam::Null, @@ -491,7 +496,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN available_items ai ON i.id = ai.id WHERE i.server_id = ? AND i.parent_id = ?{} @@ -556,7 +561,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.id = ?", @@ -601,7 +606,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, - i.parent_index_number + i.parent_index_number, i.is_folder FROM items i INNER JOIN downloaded_items di ON i.id = di.id WHERE i.server_id = ? AND i.library_id = ? @@ -639,7 +644,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -664,7 +669,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -752,7 +757,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM ranked_plays rp JOIN items i ON rp.display_id = i.id INNER JOIN downloaded_items di ON i.id = di.id @@ -800,7 +805,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN user_data ud ON i.id = ud.item_id INNER JOIN downloads d ON i.id = d.item_id @@ -934,7 +939,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN items_fts fts ON fts.rowid = i.rowid INNER JOIN downloaded_items di ON i.id = di.id @@ -980,6 +985,21 @@ impl MediaRepository for OfflineRepository { Err(RepoError::Offline) } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV is inherently online-only. + Err(RepoError::Offline) + } + + async fn get_channels(&self) -> Result { + // Plugin channels are inherently online-only. + Err(RepoError::Offline) + } + + async fn open_live_stream(&self, _item_id: &str) -> Result { + // Live streams cannot be opened offline. + Err(RepoError::Offline) + } + async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> { // Cannot report to server while offline Err(RepoError::Offline) @@ -1071,6 +1091,7 @@ impl MediaRepository for OfflineRepository { id: person_data.0, name: person_data.1, item_type: "Person".to_string(), + is_folder: false, server_id: self.server_id.clone(), parent_id: None, library_id: None, @@ -1131,7 +1152,7 @@ impl MediaRepository for OfflineRepository { i.community_rating, i.official_rating, i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, i.index_number, i.series_id, i.series_name, i.season_id, - i.season_name, i.parent_index_number + i.season_name, i.parent_index_number, i.is_folder FROM items i JOIN item_people ip ON i.id = ip.item_id INNER JOIN downloaded_items di ON i.id = di.id @@ -1242,7 +1263,7 @@ impl MediaRepository for OfflineRepository { i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \ i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \ - i.parent_index_number \ + i.parent_index_number, i.is_folder \ FROM playlist_items pi \ JOIN items i ON pi.item_id = i.id \ WHERE pi.playlist_id = ? \ @@ -1280,6 +1301,7 @@ impl MediaRepository for OfflineRepository { season_id: row.get(21)?, season_name: row.get(22)?, parent_index_number: row.get(23)?, + is_folder: row.get::<_, Option>(24)?.unwrap_or(0) != 0, }; Ok((entry_id.to_string(), cached)) }) @@ -1446,6 +1468,7 @@ mod tests { parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, name TEXT NOT NULL, item_type TEXT NOT NULL, + is_folder INTEGER DEFAULT 0, overview TEXT, genres TEXT, runtime_ticks INTEGER, @@ -1518,6 +1541,7 @@ mod tests { id: id.to_string(), name: name.to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "test-server".to_string(), parent_id: parent_id.map(|s| s.to_string()), library_id: None, diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index 0b7e4d0..1652a09 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -363,6 +363,8 @@ struct JellyfinItem { name: String, #[serde(rename = "Type")] item_type: String, + #[serde(default)] + is_folder: bool, parent_id: Option, overview: Option, genres: Option>, @@ -450,6 +452,7 @@ impl JellyfinItem { id: self.id, name: self.name, item_type: self.item_type, + is_folder: self.is_folder, server_id, parent_id: self.parent_id, library_id: None, // Not provided by Jellyfin API directly @@ -728,6 +731,7 @@ impl MediaRepository for OnlineRepository { id: album_id, name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()), item_type: "MusicAlbum".to_string(), + is_folder: true, server_id: first_track.server_id.clone(), parent_id: None, library_id: None, @@ -1139,6 +1143,105 @@ impl MediaRepository for OnlineRepository { Ok(url) } + async fn get_live_tv_channels(&self) -> Result, RepoError> { + // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with + // type "TvChannel" — playable via open_live_stream. + let endpoint = format!( + "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary", + self.user_id + ); + let response: ItemsResponse = self.get_json(&endpoint).await?; + Ok(response + .items + .into_iter() + .map(|item| item.to_media_item(self.server_url.clone())) + .collect()) + } + + async fn get_channels(&self) -> Result { + // Root list of plugin "Channels". Drill-down into a channel folder reuses + // get_items(channel_id, ...). + let endpoint = format!("/Channels?UserId={}", self.user_id); + let response: ItemsResponse = self.get_json(&endpoint).await?; + let total = response.total_record_count; + let items = response + .items + .into_iter() + .map(|item| item.to_media_item(self.server_url.clone())) + .collect(); + Ok(SearchResult { + items, + total_record_count: total, + }) + } + + async fn open_live_stream(&self, item_id: &str) -> Result { + // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the + // server opens the live stream and returns a ready-to-play transcoding URL. + // We send a minimal request; the server applies its own defaults for live. + #[derive(Debug, Serialize)] + #[serde(rename_all = "PascalCase")] + struct OpenLiveStreamRequest { + user_id: String, + #[serde(rename = "AutoOpenLiveStream")] + auto_open_live_stream: bool, + is_playback: bool, + max_streaming_bitrate: u64, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct OpenLiveStreamResponse { + #[serde(default)] + media_sources: Vec, + play_session_id: Option, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "PascalCase")] + struct LiveMediaSource { + id: String, + transcoding_url: Option, + live_stream_id: Option, + } + + let endpoint = format!("/Items/{}/PlaybackInfo", item_id); + let request = OpenLiveStreamRequest { + user_id: self.user_id.clone(), + auto_open_live_stream: true, + is_playback: true, + max_streaming_bitrate: 20_000_000, + }; + + let response: OpenLiveStreamResponse = + self.post_json_response(&endpoint, &request).await?; + + let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound { + message: "No live media source returned".to_string(), + })?; + + // The transcoding URL is server-relative; make it absolute. If the server + // did not provide one (rare for live), fall back to the HLS master endpoint. + let stream_url = match source.transcoding_url { + Some(url) => format!("{}{}", self.server_url, url), + None => format!( + "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts", + self.server_url, + item_id, + self.access_token, + source.id, + source.live_stream_id.clone().unwrap_or_default(), + ), + }; + + Ok(LiveStreamInfo { + stream_url, + play_session_id: response.play_session_id, + live_stream_id: source.live_stream_id, + media_source_id: Some(source.id), + }) + } + async fn report_playback_start( &self, item_id: &str, diff --git a/src-tauri/src/repository/types.rs b/src-tauri/src/repository/types.rs index e3c4998..0f3e572 100644 --- a/src-tauri/src/repository/types.rs +++ b/src-tauri/src/repository/types.rs @@ -104,6 +104,10 @@ pub struct MediaItem { pub name: String, #[serde(rename = "type")] pub item_type: String, + /// Whether this item is a folder/container (vs a playable leaf). Used to + /// decide whether a channel item drills into a list or plays directly. + #[serde(default)] + pub is_folder: bool, pub server_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, @@ -249,6 +253,20 @@ pub struct PlaybackInfo { pub needs_transcoding: bool, } +/// Live stream information returned from opening a Live TV / channel stream. +/// +/// Unlike on-demand video, a live channel must be "opened" before it can be +/// streamed; the server returns a transcoding URL (already absolute) plus a +/// `live_stream_id` that can later be used to close the stream. +#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveStreamInfo { + pub stream_url: String, + pub play_session_id: Option, + pub live_stream_id: Option, + pub media_source_id: Option, +} + /// Genre #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -481,6 +499,7 @@ mod tests { id: "1".to_string(), name: "Test".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "server1".to_string(), parent_id: None, library_id: None, @@ -620,6 +639,7 @@ mod tests { id: "track1".to_string(), name: "Test Track".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "server1".to_string(), parent_id: None, library_id: None, @@ -679,6 +699,7 @@ mod tests { id: "1".to_string(), name: "Track".to_string(), item_type: "Audio".to_string(), + is_folder: false, server_id: "s1".to_string(), parent_id: None, library_id: None, diff --git a/src-tauri/src/storage/schema.rs b/src-tauri/src/storage/schema.rs index 1e67db9..237fbdf 100644 --- a/src-tauri/src/storage/schema.rs +++ b/src-tauri/src/storage/schema.rs @@ -22,6 +22,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[ ("015_device_id", MIGRATION_015), ("016_autoplay_max_episodes", MIGRATION_016), ("017_downloads_resume_url", MIGRATION_017), + ("018_items_is_folder", MIGRATION_018), ]; /// Initial schema migration @@ -680,3 +681,15 @@ const MIGRATION_017: &str = r#" ALTER TABLE downloads ADD COLUMN stream_url TEXT; ALTER TABLE downloads ADD COLUMN target_dir TEXT; "#; + +/// Migration to record whether a cached item is a folder/container vs a playable +/// leaf. Needed so channel items (which can be either) route to the player or to +/// a browse list correctly. Existing cached rows predate the column and have an +/// unknown folder flag, so we force a refresh by clearing their `synced_at`, +/// causing the hybrid repository to re-fetch them from the server on next browse. +const MIGRATION_018: &str = r#" +ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0; + +-- Force re-fetch of all cached items so is_folder is populated from the server. +UPDATE items SET synced_at = NULL; +"#; diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 82a9dc6..818ef4f 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -270,6 +270,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise async remoteSessionToggleMute(sessionId: string) : Promise { return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId }); }, +/** + * List current LMS sync groups. + */ +async lmsGetSyncGroups() : Promise { + return await TAURI_INVOKE("lms_get_sync_groups"); +}, +/** + * Fuse LMS zones into a sync group. `master_mac` keeps playing and the + * `slave_macs` zones join it in sync. + */ +async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise { + return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs }); +}, +/** + * Remove a single LMS zone from its sync group (decouple one player). + */ +async lmsUnsyncPlayer(mac: string) : Promise { + return await TAURI_INVOKE("lms_unsync_player", { mac }); +}, +/** + * Dissolve an entire LMS sync group, identified by its master's MAC. + */ +async lmsDissolveSyncGroup(masterMac: string) : Promise { + return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac }); +}, /** * Set polling frequency hint based on UI state */ @@ -322,6 +347,17 @@ async playbackModeGetRemoteStatus() : Promise { async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise { return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks }); }, +/** + * Set the transferring flag on the playback mode manager. + * + * Used by the frontend remote->local flow to mark the whole two-step sequence + * as a transfer, so `player_play_tracks` starts LOCAL playback instead of + * casting back to the remote session it's leaving. Always pair `true` with a + * later `false` (including on error) so the flag can't stick. + */ +async playbackModeSetTransferring(transferring: boolean) : Promise { + return await TAURI_INVOKE("playback_mode_set_transferring", { transferring }); +}, /** * Initialize playback reporter (called after login) */ @@ -1098,6 +1134,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise { return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId }); }, +/** + * Get Live TV channels (broadcast / IPTV) for browsing + */ +async repositoryGetLiveTvChannels(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_live_tv_channels", { handle }); +}, +/** + * Get the root list of plugin "Channels" + */ +async repositoryGetChannels(handle: string) : Promise { + return await TAURI_INVOKE("repository_get_channels", { handle }); +}, +/** + * Open a live stream for a Live TV channel / live item + */ +async repositoryOpenLiveStream(handle: string, itemId: string) : Promise { + return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId }); +}, /** * Report playback start */ @@ -1503,10 +1557,30 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo" * Library (media collection) */ export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null } +/** + * Live stream information returned from opening a Live TV / channel stream. + * + * Unlike on-demand video, a live channel must be "opened" before it can be + * streamed; the server returns a transcoding URL (already absolute) plus a + * `live_stream_id` that can later be used to close the stream. + */ +export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null } +/** + * An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`. + * + * Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves + * follow it in lockstep. + */ +export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] } /** * Media item */ -export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } +export type MediaItem = { id: string; name: string; type: string; +/** + * Whether this item is a folder/container (vs a playable leaf). Used to + * decide whether a channel item drills into a list or plays directly. + */ +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null } /** * Media session type tracking the high-level playback context */ @@ -1884,7 +1958,12 @@ export type PlaylistEntry = /** * The underlying media item */ -({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { +({ id: string; name: string; type: string; +/** + * Whether this item is a folder/container (vs a playable leaf). Used to + * decide whether a channel item drills into a list or plays directly. + */ +isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & { /** * The playlist-scoped entry ID (Jellyfin's PlaylistItemId) */ diff --git a/src/lib/api/repository-client.ts b/src/lib/api/repository-client.ts index 61ef689..59a3581 100644 --- a/src/lib/api/repository-client.ts +++ b/src/lib/api/repository-client.ts @@ -11,6 +11,7 @@ import type { GetItemsOptions, SearchOptions, PlaybackInfo, + LiveStreamInfo, ImageType, ImageOptions, Genre, @@ -161,6 +162,23 @@ export class RepositoryClient { ); } + // ===== Live TV / Channels ===== + + /** Browse Live TV channels (broadcast / IPTV). */ + async getLiveTvChannels(): Promise { + return commands.repositoryGetLiveTvChannels(this.ensureHandle()); + } + + /** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */ + async getChannels(): Promise { + return commands.repositoryGetChannels(this.ensureHandle()); + } + + /** Open a live stream for a Live TV channel / live item before HLS playback. */ + async openLiveStream(itemId: string): Promise { + return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId); + } + // ===== URL Construction Methods (sync, no server call) ===== /** diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index b9f848f..ec30363 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -12,6 +12,7 @@ export type { ImageOptions, ImageType, Library, + LiveStreamInfo, MediaItem, MediaSource, MediaStream, @@ -51,6 +52,7 @@ export type ItemType = | "CollectionFolder" | "Channel" | "ChannelFolderItem" + | "TvChannel" | "Person"; export type LibraryType = @@ -63,6 +65,7 @@ export type LibraryType = | "boxsets" | "playlists" | "channels" + | "livetv" | "unknown"; export type PersonType = diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index dcc72f8..a8185ca 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -30,9 +30,10 @@ onEnded?: () => void; // Called when video playback ends naturally onNext?: () => void; // Called when user clicks next episode button hasNext?: boolean; // Whether there is a next episode available + isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting } - let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props(); + let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false, isLive = false }: Props = $props(); // The id this player instance reports progress against. Snapshotted from the // media prop so a late reportStop (e.g. from onDestroy during autoplay @@ -488,12 +489,15 @@ // Load series audio preference (for TV shows) await loadSeriesAudioPreference(); - // Report progress every 10 seconds while playing - progressInterval = setInterval(() => { - if (isPlaying && !isSeeking && onReportProgress) { - onReportProgress(currentTime, false, reportMediaId); - } - }, 10000); + // Report progress every 10 seconds while playing. Live streams have no + // meaningful position to report, so skip progress reporting entirely. + if (!isLive) { + progressInterval = setInterval(() => { + if (isPlaying && !isSeeking && onReportProgress) { + onReportProgress(currentTime, false, reportMediaId); + } + }, 10000); + } // Debug logging every second debugLogInterval = setInterval(() => { @@ -555,8 +559,8 @@ } } - // Report stop when component is destroyed - if (onReportStop && currentTime > 0) { + // Report stop when component is destroyed (skip for live - no resume tracking) + if (!isLive && onReportStop && currentTime > 0) { onReportStop(currentTime, reportMediaId); } }); @@ -749,8 +753,8 @@ function handlePlay() { isPlaying = true; startTimeUpdates(); // Start RAF loop for smooth time updates - // Report playback start on first play - if (!hasReportedStart && onReportStart) { + // Report playback start on first play (skip for live - no resume tracking) + if (!isLive && !hasReportedStart && onReportStart) { onReportStart(currentTime, reportMediaId); hasReportedStart = true; } @@ -768,8 +772,8 @@ function handleEnded() { isPlaying = false; stopTimeUpdates(); // Stop RAF loop when ended - // Report stop when video ends - if (onReportStop) { + // Report stop when video ends (skip for live - no resume tracking) + if (!isLive && onReportStop) { onReportStop(currentTime, reportMediaId); } // Notify parent that video has ended (for next episode popup) @@ -1413,26 +1417,35 @@

{media?.name || "Video"}

- -
- {formatTime(currentTime)} - isDraggingSeekBar = true} - onmouseup={() => isDraggingSeekBar = false} - ontouchstart={() => isDraggingSeekBar = true} - ontouchend={() => isDraggingSeekBar = false} - class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 - [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" - /> - {formatTime(duration)} -
+ + {#if isLive} +
+ + + LIVE + +
+ {:else} +
+ {formatTime(currentTime)} + isDraggingSeekBar = true} + onmouseup={() => isDraggingSeekBar = false} + ontouchstart={() => isDraggingSeekBar = true} + ontouchend={() => isDraggingSeekBar = false} + class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 + [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full" + /> + {formatTime(duration)} +
+ {/if}
diff --git a/src/lib/components/sessions/SessionPickerModal.svelte b/src/lib/components/sessions/SessionPickerModal.svelte index 16c9300..ff74bbf 100644 --- a/src/lib/components/sessions/SessionPickerModal.svelte +++ b/src/lib/components/sessions/SessionPickerModal.svelte @@ -4,6 +4,7 @@ import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode"; import { playbackPosition } from "$lib/stores/player"; + import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync"; import type { Session } from "$lib/api/types"; interface Props { @@ -14,6 +15,20 @@ let { isOpen = false, onClose, onSelectSession }: Props = $props(); + // MACs currently part of any sync group, for rendering toggle state. + const groupedMacs = $derived(new Set(($lmsSync.groups ?? []).flatMap( + (g) => [g.masterMac, ...(g.slaveMacs ?? [])] + ))); + + // The sync master is the LMS zone we're currently controlling. Zones can only + // be fused once we have a master to fuse them into. + const masterMac = $derived(isLmsSession($selectedSession) ? macForSession($selectedSession) : null); + + function isZoneFused(session: Session): boolean { + const mac = macForSession(session); + return mac !== null && groupedMacs.has(mac); + } + async function handleSessionSelect(session: Session) { try { // Transfer playback to remote session, resuming at our current position @@ -31,6 +46,32 @@ } } + // Toggle an LMS zone in/out of the current master's sync group. If there's no + // master yet (we're not controlling an LMS zone), the click falls back to a + // normal transfer so the user can start playing on a zone first. + async function handleLmsZoneToggle(session: Session) { + const zoneMac = macForSession(session); + if (!zoneMac) return; + + if (!masterMac) { + // No master yet — start playback on this zone; it becomes the master. + await handleSessionSelect(session); + await lmsSync.refresh(); + return; + } + + try { + if (isZoneFused(session)) { + await lmsSync.decoupleZone(zoneMac); + } else { + await lmsSync.fuseZone(masterMac, zoneMac); + } + } catch (error) { + console.error("Failed to toggle LMS zone:", error); + // Error is surfaced via the lmsSync store + } + } + async function handleTransferToLocal() { try { await playbackMode.transferToLocal(); @@ -80,6 +121,7 @@ $effect(() => { if (isOpen) { sessions.refresh(); + lmsSync.refresh(); } }); @@ -167,14 +209,24 @@ {#each $controllableSessions as session (session.id)} {#if session.id !== $selectedSession?.id} + {@const lmsZone = isLmsSession(session)} + {@const fused = lmsZone && isZoneFused(session)} +
+ {/if} + {#if $transferError}
diff --git a/src/lib/stores/library.ts b/src/lib/stores/library.ts index 08daacb..391bb40 100644 --- a/src/lib/stores/library.ts +++ b/src/lib/stores/library.ts @@ -152,6 +152,47 @@ function createLibraryStore() { } } + // Load Live TV channels (broadcast / IPTV) into the items list. Live TV uses a + // dedicated Jellyfin endpoint rather than the generic /Items browse. + async function loadLiveTvChannels() { + update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); + try { + const repo = auth.getRepository(); + const channels = await repo.getLiveTvChannels(); + update((s) => ({ + ...s, + items: channels, + totalItems: channels.length, + loadingCount: Math.max(0, s.loadingCount - 1), + })); + return channels; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load Live TV channels"; + update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message })); + throw error; + } + } + + // Load the root list of plugin "Channels" into the items list. + async function loadChannels() { + update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); + try { + const repo = auth.getRepository(); + const result = await repo.getChannels(); + update((s) => ({ + ...s, + items: result.items, + totalItems: result.totalRecordCount, + loadingCount: Math.max(0, s.loadingCount - 1), + })); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load channels"; + update((s) => ({ ...s, loadingCount: Math.max(0, s.loadingCount - 1), error: message })); + throw error; + } + } + async function loadItem(itemId: string) { update((s) => ({ ...s, loadingCount: s.loadingCount + 1, error: null })); @@ -297,6 +338,8 @@ function createLibraryStore() { subscribe, loadLibraries, loadItems, + loadLiveTvChannels, + loadChannels, loadItem, search, setCurrentLibrary, diff --git a/src/lib/stores/lmsSync.test.ts b/src/lib/stores/lmsSync.test.ts new file mode 100644 index 0000000..508a5ea --- /dev/null +++ b/src/lib/stores/lmsSync.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { get } from "svelte/store"; +import type { Session } from "$lib/api/types"; + +const mockInvoke = vi.fn(); +vi.mock("$lib/api/bindings", () => ({ + commands: { + lmsGetSyncGroups: () => mockInvoke("lms_get_sync_groups"), + lmsCreateSyncGroup: (masterMac: string, slaveMacs: string[]) => + mockInvoke("lms_create_sync_group", { masterMac, slaveMacs }), + lmsUnsyncPlayer: (mac: string) => mockInvoke("lms_unsync_player", { mac }), + lmsDissolveSyncGroup: (masterMac: string) => + mockInvoke("lms_dissolve_sync_group", { masterMac }), + }, +})); + +import { lmsSync, isLmsSession, macForSession } from "./lmsSync"; + +function session(deviceId: string | null): Session { + return { id: "s", deviceId } as unknown as Session; +} + +describe("LMS session detection", () => { + it("detects LMS zones by the lms- device-id prefix", () => { + expect(isLmsSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe(true); + expect(isLmsSession(session("chromecast-123"))).toBe(false); + expect(isLmsSession(session(null))).toBe(false); + expect(isLmsSession(null)).toBe(false); + }); + + it("recovers the MAC from an LMS session, null otherwise", () => { + expect(macForSession(session("lms-aa:bb:cc:dd:ee:ff"))).toBe("aa:bb:cc:dd:ee:ff"); + expect(macForSession(session("web-xyz"))).toBeNull(); + expect(macForSession(null)).toBeNull(); + }); +}); + +describe("lmsSync store", () => { + beforeEach(() => { + mockInvoke.mockReset(); + lmsSync.reset(); + }); + + it("fusing preserves existing slaves and adds the new zone", async () => { + // Initial group: master M with slave A. + mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + await lmsSync.refresh(); + + // create returns void; refresh after returns the updated group. + mockInvoke.mockResolvedValueOnce(undefined); + mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A", "B"], slaveNames: [] }]); + + await lmsSync.fuseZone("M", "B"); + + expect(mockInvoke).toHaveBeenCalledWith("lms_create_sync_group", { + masterMac: "M", + slaveMacs: ["A", "B"], + }); + expect(get(lmsSync).groups[0].slaveMacs).toEqual(["A", "B"]); + }); + + it("decoupling a slave unsyncs just that player", async () => { + mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + await lmsSync.refresh(); + + mockInvoke.mockResolvedValueOnce(undefined); // unsync + mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: [], slaveNames: [] }]); + + await lmsSync.decoupleZone("A"); + + expect(mockInvoke).toHaveBeenCalledWith("lms_unsync_player", { mac: "A" }); + }); + + it("decoupling the master dissolves the whole group", async () => { + mockInvoke.mockResolvedValueOnce([{ masterMac: "M", masterName: "M", slaveMacs: ["A"], slaveNames: [] }]); + await lmsSync.refresh(); + + mockInvoke.mockResolvedValueOnce(undefined); // dissolve + mockInvoke.mockResolvedValueOnce([]); + + await lmsSync.decoupleZone("M"); + + expect(mockInvoke).toHaveBeenCalledWith("lms_dissolve_sync_group", { masterMac: "M" }); + }); + + it("treats a missing plugin (refresh error) as no groups, not fatal", async () => { + mockInvoke.mockRejectedValueOnce(new Error("404")); + await lmsSync.refresh(); + expect(get(lmsSync).groups).toEqual([]); + }); +}); diff --git a/src/lib/stores/lmsSync.ts b/src/lib/stores/lmsSync.ts new file mode 100644 index 0000000..8c8b103 --- /dev/null +++ b/src/lib/stores/lmsSync.ts @@ -0,0 +1,133 @@ +/** + * LMS multi-room sync ("fuse zones") store. + * + * The JellyLMS plugin registers each LMS player as a Jellyfin session whose + * device id is `lms-{MacAddress}`. We use that prefix both to detect which cast + * targets are fuseable LMS zones and to recover the MAC the SyncGroups API needs. + * + * Fusing model (per product decision): the currently-controlled zone is the sync + * master; other selected zones join it. Toggling an already-grouped zone off + * decouples just that zone; toggling the master off dissolves the group. + * + * TRACES: UR-010 | JA-021, JA-025 | DR-037 + */ + +import { writable, get } from "svelte/store"; +import { commands } from "$lib/api/bindings"; +import type { Session } from "$lib/api/types"; +import type { LmsSyncGroup } from "$lib/api/bindings"; + +const LMS_DEVICE_PREFIX = "lms-"; + +/** Is this cast target an LMS zone (and therefore fuseable)? */ +export function isLmsSession(session: Session | null | undefined): boolean { + return !!session?.deviceId?.startsWith(LMS_DEVICE_PREFIX); +} + +/** Recover the LMS player MAC from a session, or null if it isn't an LMS zone. */ +export function macForSession(session: Session | null | undefined): string | null { + const deviceId = session?.deviceId; + if (!deviceId?.startsWith(LMS_DEVICE_PREFIX)) return null; + return deviceId.slice(LMS_DEVICE_PREFIX.length); +} + +interface LmsSyncState { + groups: LmsSyncGroup[]; + isBusy: boolean; + error: string | null; +} + +function createLmsSyncStore() { + const { subscribe, update, set } = writable({ + groups: [], + isBusy: false, + error: null, + }); + + /** Refresh the list of current sync groups from the server. */ + async function refresh(): Promise { + try { + const groups = await commands.lmsGetSyncGroups(); + update((s) => ({ ...s, groups, error: null })); + } catch (error) { + // The plugin may not be installed; treat as "no groups" rather than fatal. + console.warn("[LmsSync] Failed to load sync groups:", error); + update((s) => ({ ...s, groups: [] })); + } + } + + /** All MACs that are part of any sync group (master or slave). */ + function groupedMacs(): Set { + const macs = new Set(); + for (const g of get({ subscribe }).groups) { + macs.add(g.masterMac); + for (const slave of g.slaveMacs ?? []) macs.add(slave); + } + return macs; + } + + /** + * Add an LMS zone to the master's sync group (fuse it in). + * Preserves any zones already grouped with the master. + */ + async function fuseZone(masterMac: string, zoneMac: string): Promise { + if (masterMac === zoneMac) return; + update((s) => ({ ...s, isBusy: true, error: null })); + try { + const existing = get({ subscribe }).groups.find((g) => g.masterMac === masterMac); + const slaves = new Set(existing?.slaveMacs ?? []); + slaves.add(zoneMac); + await commands.lmsCreateSyncGroup(masterMac, [...slaves]); + await refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fuse zone"; + update((s) => ({ ...s, error: message })); + throw error; + } finally { + update((s) => ({ ...s, isBusy: false })); + } + } + + /** + * Remove a single zone from its group. If the zone is a group's master, the + * whole group is dissolved (a group can't outlive its master). + */ + async function decoupleZone(zoneMac: string): Promise { + update((s) => ({ ...s, isBusy: true, error: null })); + try { + const asMaster = get({ subscribe }).groups.find((g) => g.masterMac === zoneMac); + if (asMaster) { + await commands.lmsDissolveSyncGroup(zoneMac); + } else { + await commands.lmsUnsyncPlayer(zoneMac); + } + await refresh(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to decouple zone"; + update((s) => ({ ...s, error: message })); + throw error; + } finally { + update((s) => ({ ...s, isBusy: false })); + } + } + + function clearError(): void { + update((s) => ({ ...s, error: null })); + } + + function reset(): void { + set({ groups: [], isBusy: false, error: null }); + } + + return { + subscribe, + refresh, + groupedMacs, + fuseZone, + decoupleZone, + clearError, + reset, + }; +} + +export const lmsSync = createLmsSyncStore(); diff --git a/src/lib/stores/playbackMode.ts b/src/lib/stores/playbackMode.ts index 43873b4..6b7bb6c 100644 --- a/src/lib/stores/playbackMode.ts +++ b/src/lib/stores/playbackMode.ts @@ -198,6 +198,12 @@ function createPlaybackModeStore() { // Get repository for handle (backend will fetch playback info via player_play_tracks) const repository = auth.getRepository(); + // Mark the whole sequence as a transfer in the *Rust* manager too. Without + // this, player_play_tracks sees mode=Remote and casts the track back to the + // remote session instead of playing it locally (the frontend's own + // isTransferring flag is invisible to Rust). Cleared in `finally`. + await commands.playbackModeSetTransferring(true); + // Start local playback (events allowed through because isTransferring=true) // Use player_play_tracks - backend fetches all metadata from single ID const repositoryHandle = repository.getHandle(); @@ -248,6 +254,14 @@ function createPlaybackModeStore() { console.error("Transfer to local failed:", error); throw error; } finally { + // Always lower the Rust transferring flag so it can't stick on if any step + // above threw (transfer_to_local lowers it on success, but not if we never + // reached it). Safe to call unconditionally. + try { + await commands.playbackModeSetTransferring(false); + } catch (e) { + console.warn("[PlaybackMode] Failed to clear transferring flag:", e); + } currentTransferAbort = null; } } diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index 3f22de5..d89a819 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -76,6 +76,22 @@ return; } + // Live TV channels use a dedicated endpoint + if (lib.collectionType === "livetv") { + library.setCurrentLibrary(lib); + library.clearGenres(); + await library.loadLiveTvChannels(); + return; + } + + // Plugin "Channels" root list uses a dedicated endpoint + if (lib.collectionType === "channels") { + library.setCurrentLibrary(lib); + library.clearGenres(); + await library.loadChannels(); + return; + } + // For other library types, load items normally library.setCurrentLibrary(lib); library.clearGenres(); @@ -97,6 +113,11 @@ if ("type" in item) { // It's a MediaItem const mediaItem = item as MediaItem; + // A ChannelFolderItem can be a folder (drill in) or a playable leaf. + if (mediaItem.type === "ChannelFolderItem" && !mediaItem.isFolder) { + goto(`/player/${mediaItem.id}`); + return; + } switch (mediaItem.type) { case "Series": case "Movie": @@ -111,7 +132,8 @@ goto(`/library/${mediaItem.id}`); break; case "Episode": - // Episodes play directly + case "TvChannel": + // Episodes and live TV channels play directly goto(`/player/${mediaItem.id}`); break; default: diff --git a/src/routes/library/[id]/+page.svelte b/src/routes/library/[id]/+page.svelte index 26226b9..f34ec5c 100644 --- a/src/routes/library/[id]/+page.svelte +++ b/src/routes/library/[id]/+page.svelte @@ -187,6 +187,12 @@ goto(`/library/${clickedItem.id}`); return; } + // A ChannelFolderItem can be either a folder (drill in) or a playable leaf. + // Route non-folder channel items straight to the player. + if (clickedItem.type === "ChannelFolderItem" && !clickedItem.isFolder) { + goto(`/player/${clickedItem.id}`); + return; + } switch (clickedItem.type) { case "Series": case "Season": diff --git a/src/routes/player/[id]/+page.svelte b/src/routes/player/[id]/+page.svelte index d54c954..d70dc57 100644 --- a/src/routes/player/[id]/+page.svelte +++ b/src/routes/player/[id]/+page.svelte @@ -57,6 +57,7 @@ let streamUrl = $state(null); let mediaSourceId = $state(null); let isVideo = $state(false); + let isLive = $state(false); // Whether this is a live stream (Live TV channel) - no seek/resume let videoInitialPosition = $state(0); // Position in seconds to seek to after video loads let videoNeedsTranscoding = $state(false); // Whether video needs transcoding (HEVC/10-bit) let isOfflinePlayback = $state(false); // Whether playing from local file @@ -104,6 +105,15 @@ } }); + // A playable channel leaf (ChannelFolderItem) has no dedicated item type, so + // treat it as video when it carries a video media stream. + function isVideoChannelItem(item: MediaItem): boolean { + return ( + item.type === "ChannelFolderItem" && + (item.mediaStreams?.some((s) => s.type === "Video") ?? false) + ); + } + async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) { loading = true; error = null; @@ -118,8 +128,9 @@ currentMedia = item; // Check if this is a non-playable collection type that should be viewed in library instead - const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist"]; - if (collectionTypes.includes(item.type)) { + const collectionTypes = ["MusicAlbum", "MusicArtist", "Series", "Season", "Folder", "CollectionFolder", "Playlist", "Channel"]; + // A ChannelFolderItem that is itself a folder is a container, not playable. + if (collectionTypes.includes(item.type) || (item.type === "ChannelFolderItem" && item.isFolder)) { console.log("loadAndPlay: Redirecting collection type to library:", item.type); goto(`/library/${id}`); return; @@ -132,7 +143,8 @@ const alreadyPlayingMedia = get(storeCurrentMedia); if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) { console.log("loadAndPlay: Track already playing, showing UI without restarting"); - isVideo = item.type === "Movie" || item.type === "Episode"; + isLive = item.type === "TvChannel"; + isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); isPlaying = true; loading = false; // hasNext/hasPrevious come from the event-driven queue store. @@ -143,8 +155,10 @@ return; } - // Determine if this is video content (Movie and Episode are video types) - isVideo = item.type === "Movie" || item.type === "Episode"; + // Determine if this is video content (Movie, Episode, live TV channels, and + // channel leaf items that carry a video stream). + isLive = item.type === "TvChannel"; + isVideo = item.type === "Movie" || item.type === "Episode" || isLive || isVideoChannelItem(item); // When switching to video, stop audio playback and clear the queue // This prevents audio from continuing in the background and clears stale state @@ -164,7 +178,8 @@ const userId = auth.getUserId(); console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart); - if (!startPosition && !forceRestart && userId) { + // Live streams have no fixed position - never resume. + if (!startPosition && !forceRestart && userId && !isLive) { try { const progress = await commands.storageGetPlaybackProgress(userId, id); console.log("Resume check - retrieved progress:", progress); @@ -251,6 +266,22 @@ // Online playback - get playback info from server isOfflinePlayback = false; const repo = auth.getRepository(); + + if (isLive) { + // Live TV channels must be "opened" before streaming; the server returns + // a ready-to-play HLS transcoding URL. No resume, no seek, no progress. + console.log("loadAndPlay: Opening live stream for channel:", id); + const liveInfo = await repo.openLiveStream(id); + console.log("loadAndPlay: Live stream URL:", liveInfo.streamUrl); + mediaSourceId = liveInfo.mediaSourceId; + streamUrl = liveInfo.streamUrl; + videoNeedsTranscoding = true; + videoInitialPosition = 0; + isPlaying = true; + loading = false; + return; + } + console.log("loadAndPlay: Getting playback info"); const playbackInfo = await repo.getPlaybackInfo(id); console.log("loadAndPlay: Got playback info, mediaSourceId:", playbackInfo.mediaSourceId); @@ -613,6 +644,7 @@ mediaSourceId={mediaSourceId ?? undefined} initialPosition={videoInitialPosition} needsTranscoding={videoNeedsTranscoding} + {isLive} onClose={handleClose} onSeek={handleVideoSeek} onReportStart={handleReportStart}