Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
This commit is contained in:
parent
ff8f35084b
commit
f1d25c4f4d
@ -51,6 +51,22 @@ pub async fn playback_mode_transfer_to_remote(
|
|||||||
manager.0.transfer_to_remote(session_id, position).await
|
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
|
/// Transfer playback from remote session back to local device
|
||||||
///
|
///
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::PlayerStateWrapper;
|
use super::PlayerStateWrapper;
|
||||||
|
use crate::jellyfin::client::LmsSyncGroup;
|
||||||
|
|
||||||
/// Play items on a remote Jellyfin session (casting)
|
/// Play items on a remote Jellyfin session (casting)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
|
|||||||
Err("Jellyfin client not configured".to_string())
|
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<Vec<LmsSyncGroup>, 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<String>,
|
||||||
|
) -> 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -384,6 +384,82 @@ impl JellyfinClient {
|
|||||||
let sessions = self.get_sessions().await?;
|
let sessions = self.get_sessions().await?;
|
||||||
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
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<Vec<LmsSyncGroup>, 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<String>,
|
||||||
|
) -> 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<String>,
|
||||||
|
#[serde(default, alias = "SlaveNames")]
|
||||||
|
pub slave_names: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default value for supports_remote_control when missing from API
|
/// Default value for supports_remote_control when missing from API
|
||||||
|
|||||||
@ -52,11 +52,13 @@ use commands::{
|
|||||||
// Remote session control commands
|
// Remote session control commands
|
||||||
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
||||||
remote_session_toggle_mute,
|
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
|
// Session polling commands
|
||||||
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
||||||
// Playback mode commands
|
// Playback mode commands
|
||||||
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
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_mode_get_remote_status,
|
||||||
// Playback reporting commands
|
// Playback reporting commands
|
||||||
playback_reporter_init, playback_reporter_destroy,
|
playback_reporter_init, playback_reporter_destroy,
|
||||||
@ -417,6 +419,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
remote_session_seek,
|
remote_session_seek,
|
||||||
remote_session_set_volume,
|
remote_session_set_volume,
|
||||||
remote_session_toggle_mute,
|
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
|
// Session polling commands
|
||||||
sessions_set_polling_hint,
|
sessions_set_polling_hint,
|
||||||
sessions_poll_now,
|
sessions_poll_now,
|
||||||
@ -427,6 +434,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
playback_mode_transfer_to_remote,
|
playback_mode_transfer_to_remote,
|
||||||
playback_mode_get_remote_status,
|
playback_mode_get_remote_status,
|
||||||
playback_mode_transfer_to_local,
|
playback_mode_transfer_to_local,
|
||||||
|
playback_mode_set_transferring,
|
||||||
// Playback reporting commands
|
// Playback reporting commands
|
||||||
playback_reporter_init,
|
playback_reporter_init,
|
||||||
playback_reporter_destroy,
|
playback_reporter_destroy,
|
||||||
|
|||||||
@ -81,6 +81,19 @@ impl PlaybackModeManager {
|
|||||||
self.is_transferring.load(Ordering::Relaxed)
|
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
|
/// Send volume command to remote session
|
||||||
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||||
#[allow(dead_code)] // Called from Android JNI callback
|
#[allow(dead_code)] // Called from Android JNI callback
|
||||||
|
|||||||
@ -270,6 +270,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null>
|
|||||||
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
|
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
|
||||||
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
|
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* List current LMS sync groups.
|
||||||
|
*/
|
||||||
|
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
|
||||||
|
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<null> {
|
||||||
|
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<null> {
|
||||||
|
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<null> {
|
||||||
|
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Set polling frequency hint based on UI state
|
* Set polling frequency hint based on UI state
|
||||||
*/
|
*/
|
||||||
@ -322,6 +347,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
|
|||||||
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
|
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
|
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<null> {
|
||||||
|
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Initialize playback reporter (called after login)
|
* Initialize playback reporter (called after login)
|
||||||
*/
|
*/
|
||||||
@ -1503,6 +1539,13 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
|
|||||||
* Library (media collection)
|
* Library (media collection)
|
||||||
*/
|
*/
|
||||||
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
|
export type Library = { id: string; name: string; collectionType: string; imageTag?: 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
|
* Media item
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
import { sessions, controllableSessions, selectedSession } from "$lib/stores";
|
||||||
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
|
||||||
import { playbackPosition } from "$lib/stores/player";
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
|
import { lmsSync, isLmsSession, macForSession } from "$lib/stores/lmsSync";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -14,6 +15,20 @@
|
|||||||
|
|
||||||
let { isOpen = false, onClose, onSelectSession }: Props = $props();
|
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) {
|
async function handleSessionSelect(session: Session) {
|
||||||
try {
|
try {
|
||||||
// Transfer playback to remote session, resuming at our current position
|
// 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() {
|
async function handleTransferToLocal() {
|
||||||
try {
|
try {
|
||||||
await playbackMode.transferToLocal();
|
await playbackMode.transferToLocal();
|
||||||
@ -80,6 +121,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
sessions.refresh();
|
sessions.refresh();
|
||||||
|
lmsSync.refresh();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -167,14 +209,24 @@
|
|||||||
<!-- Available sessions -->
|
<!-- Available sessions -->
|
||||||
{#each $controllableSessions as session (session.id)}
|
{#each $controllableSessions as session (session.id)}
|
||||||
{#if session.id !== $selectedSession?.id}
|
{#if session.id !== $selectedSession?.id}
|
||||||
|
{@const lmsZone = isLmsSession(session)}
|
||||||
|
{@const fused = lmsZone && isZoneFused(session)}
|
||||||
<button
|
<button
|
||||||
onclick={() => handleSessionSelect(session)}
|
onclick={() => (lmsZone ? handleLmsZoneToggle(session) : handleSessionSelect(session))}
|
||||||
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left"
|
disabled={$lmsSync.isBusy}
|
||||||
|
aria-pressed={lmsZone ? fused : undefined}
|
||||||
|
class="w-full p-4 rounded-lg border transition-all text-left disabled:opacity-60
|
||||||
|
{fused
|
||||||
|
? 'border-[var(--color-jellyfin)] bg-[var(--color-jellyfin)]/10'
|
||||||
|
: 'border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5'}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
|
<div class="w-10 h-10 rounded-lg bg-gray-800 flex items-center justify-center flex-shrink-0">
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
{#if getSessionIcon(session.client) === "tv"}
|
{#if lmsZone}
|
||||||
|
<!-- speaker icon for LMS zones -->
|
||||||
|
<path d="M12 2a1 1 0 011 1v18a1 1 0 01-1 1H6a2 2 0 01-2-2V4a2 2 0 012-2h6zm0 2H6v16h6V4zm-3 9a2 2 0 110 4 2 2 0 010-4zm0-2a4 4 0 100 8 4 4 0 000-8zm0-4a1 1 0 110 2 1 1 0 010-2zm9 0h2v2h-2V7zm0 4h2v2h-2v-2z" />
|
||||||
|
{:else if getSessionIcon(session.client) === "tv"}
|
||||||
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
|
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
|
||||||
{:else if getSessionIcon(session.client) === "web"}
|
{:else if getSessionIcon(session.client) === "web"}
|
||||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
|
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-5 14H4v-4h11v4zm0-5H4V9h11v4zm5 5h-4V9h4v9z" />
|
||||||
@ -187,14 +239,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
|
<h3 class="font-medium text-white truncate">{session.deviceName}</h3>
|
||||||
<p class="text-sm text-gray-400 truncate">{session.client}</p>
|
<p class="text-sm text-gray-400 truncate">
|
||||||
|
{#if lmsZone}
|
||||||
|
{fused ? "In sync group — tap to remove" : masterMac ? "Tap to add to group" : "Speaker zone"}
|
||||||
|
{:else}
|
||||||
|
{session.client}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
{#if session.nowPlayingItem}
|
{#if session.nowPlayingItem}
|
||||||
<p class="text-xs text-gray-500 truncate mt-1">
|
<p class="text-xs text-gray-500 truncate mt-1">
|
||||||
Playing: {session.nowPlayingItem.name}
|
Playing: {session.nowPlayingItem.name}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if session.playState}
|
{#if lmsZone}
|
||||||
|
<!-- Fuse toggle indicator -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{#if fused}
|
||||||
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="9" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if session.playState}
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
{#if session.playState.isPaused}
|
{#if session.playState.isPaused}
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@ -295,6 +366,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- LMS sync error -->
|
||||||
|
{#if $lmsSync.error}
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">{$lmsSync.error}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => lmsSync.clearError()}
|
||||||
|
class="text-white hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Error Display -->
|
<!-- Error Display -->
|
||||||
{#if $transferError}
|
{#if $transferError}
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
|
<div class="absolute bottom-0 left-0 right-0 bg-red-500/90 text-white px-6 py-3 flex items-center justify-between z-10 rounded-b-2xl">
|
||||||
|
|||||||
91
src/lib/stores/lmsSync.test.ts
Normal file
91
src/lib/stores/lmsSync.test.ts
Normal file
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
src/lib/stores/lmsSync.ts
Normal file
133
src/lib/stores/lmsSync.ts
Normal file
@ -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<LmsSyncState>({
|
||||||
|
groups: [],
|
||||||
|
isBusy: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Refresh the list of current sync groups from the server. */
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
const macs = new Set<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
@ -198,6 +198,12 @@ function createPlaybackModeStore() {
|
|||||||
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
// Get repository for handle (backend will fetch playback info via player_play_tracks)
|
||||||
const repository = auth.getRepository();
|
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)
|
// Start local playback (events allowed through because isTransferring=true)
|
||||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||||
const repositoryHandle = repository.getHandle();
|
const repositoryHandle = repository.getHandle();
|
||||||
@ -248,6 +254,14 @@ function createPlaybackModeStore() {
|
|||||||
console.error("Transfer to local failed:", error);
|
console.error("Transfer to local failed:", error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} 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;
|
currentTransferAbort = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user