From f1d25c4f4d8267d8049e6904c9327ddd98d0c2a9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 26 Jun 2026 19:27:37 +0200 Subject: [PATCH] Add support for fusing/unfusing JellyLMS zones into synchronized multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id). --- src-tauri/src/commands/playback_mode.rs | 16 +++ src-tauri/src/commands/player/remote.rs | 90 ++++++++++++ src-tauri/src/jellyfin/client.rs | 76 ++++++++++ src-tauri/src/lib.rs | 10 +- src-tauri/src/playback_mode/mod.rs | 13 ++ src/lib/api/bindings.ts | 43 ++++++ .../sessions/SessionPickerModal.svelte | 102 +++++++++++++- src/lib/stores/lmsSync.test.ts | 91 ++++++++++++ src/lib/stores/lmsSync.ts | 133 ++++++++++++++++++ src/lib/stores/playbackMode.ts | 14 ++ 10 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 src/lib/stores/lmsSync.test.ts create mode 100644 src/lib/stores/lmsSync.ts 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/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..455ef19 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, @@ -417,6 +419,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 +434,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, 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/lib/api/bindings.ts b/src/lib/api/bindings.ts index 82a9dc6..63856f6 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) */ @@ -1503,6 +1539,13 @@ export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo" * Library (media collection) */ 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 */ 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/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; } }