/** * Playback mode store - Thin wrapper over Rust PlaybackModeManager * * Manages transitions between Local (device playback) and Remote (controlling * another Jellyfin session) playback modes. * * Most business logic moved to Rust (src-tauri/src/playback_mode/mod.rs) * * TRACES: UR-010 | IR-012 | DR-037 */ import { writable, get, derived } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { sessions, selectedSession } from "./sessions"; import { auth } from "./auth"; import { ticksToSeconds } from "$lib/utils/playbackUnits"; export type PlaybackMode = "local" | "remote" | "idle"; interface PlaybackModeState { mode: PlaybackMode; remoteSessionId: string | null; isTransferring: boolean; transferError: string | null; } interface RustPlaybackMode { type: "local" | "remote" | "idle"; session_id?: string; } function createPlaybackModeStore() { const initialState: PlaybackModeState = { mode: "idle", remoteSessionId: null, isTransferring: false, transferError: null, }; const { subscribe, update } = writable(initialState); // Track ongoing transfer promise to allow cancellation let currentTransferAbort: (() => void) | null = null; /** * Refresh mode from Rust backend */ async function refreshMode(): Promise { try { const rustMode = await invoke("playback_mode_get_current"); update((s) => ({ ...s, mode: rustMode.type, remoteSessionId: rustMode.type === "remote" ? rustMode.session_id || null : null, })); } catch (error) { console.error("Failed to get playback mode:", error); } } /** * Set playback mode directly (for internal use) */ function setMode(mode: PlaybackMode, remoteSessionId: string | null = null): void { update((s) => ({ ...s, mode, remoteSessionId })); } /** * Transfer playback from local to remote session * Rust backend handles all the heavy lifting: * - Sends play command with StartPositionTicks * - Polls remote session until track loads * - Stops local playback */ async function transferToRemote(sessionId: string): Promise { console.log("[PlaybackMode] Transferring to remote session:", sessionId); update((s) => ({ ...s, isTransferring: true, transferError: null })); let aborted = false; currentTransferAbort = () => { aborted = true; update((s) => ({ ...s, isTransferring: false, transferError: "Transfer cancelled", })); }; try { // Rust handles everything - just wait for it to complete // It includes its own 5-second timeout for track loading console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId); await invoke("playback_mode_transfer_to_remote", { sessionId }); console.log("[PlaybackMode] Invoke completed successfully"); if (aborted) { console.log("[PlaybackMode] Transfer was cancelled"); return; } // Update local state sessions.selectSession(sessionId); update((s) => ({ ...s, mode: "remote", remoteSessionId: sessionId, isTransferring: false, })); console.log("[PlaybackMode] Successfully transferred to remote"); } catch (error) { if (aborted) { console.log("[PlaybackMode] Transfer was cancelled"); return; } const message = error instanceof Error ? error.message : "Failed to transfer playback"; update((s) => ({ ...s, isTransferring: false, transferError: message, })); console.error("Transfer to remote failed:", error); throw error; } finally { currentTransferAbort = null; } } /** * Transfer playback from remote to local * * Note: Currently hybrid - Rust stops remote, but TypeScript handles * loading media since repository isn't migrated yet (Phase 3). * Will be fully migrated to Rust after Phase 3. */ async function transferToLocal(): Promise { console.log("[PlaybackMode] Transferring to local"); update((s) => ({ ...s, isTransferring: true, transferError: null })); let aborted = false; currentTransferAbort = () => { aborted = true; update((s) => ({ ...s, isTransferring: false, transferError: "Transfer cancelled", })); }; try { const currentMode = get({ subscribe }); if (currentMode.mode !== "remote" || !currentMode.remoteSessionId) { throw new Error("Not in remote mode"); } // Get current remote session state const session = get(selectedSession); if (!session || !session.nowPlayingItem) { // No active playback on remote, just switch to local mode sessions.selectSession(null); update((s) => ({ ...s, mode: "local", remoteSessionId: null, isTransferring: false, })); return; } const nowPlaying = session.nowPlayingItem; const positionTicks = session.playState?.positionTicks ?? 0; const positionSeconds = ticksToSeconds(positionTicks); // Handle both camelCase and PascalCase field names (API might return either) const itemId = (nowPlaying as any).id || (nowPlaying as any).Id; const itemName = (nowPlaying as any).name || (nowPlaying as any).Name; console.log("[PlaybackMode] Current remote item:", itemName, "position:", positionSeconds, "id:", itemId); if (!itemId) { throw new Error("Cannot transfer: remote item has no ID"); } if (aborted) return; // Get repository for handle (backend will fetch playback info via player_play_tracks) const repository = auth.getRepository(); // Start local playback (events allowed through because isTransferring=true) // Use player_play_tracks - backend fetches all metadata from single ID const repositoryHandle = repository.getHandle(); await invoke("player_play_tracks", { repositoryHandle, request: { trackIds: [itemId], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "", }, }, }); if (aborted) return; // Wait briefly for media to load await new Promise((resolve) => setTimeout(resolve, 500)); // Seek to position if not at the very start if (positionSeconds > 0.5) { await invoke("player_seek", { position: positionSeconds }); } if (aborted) return; // Let Rust handle stopping remote playback await invoke("playback_mode_transfer_to_local", { currentItemId: itemId, positionTicks, }); if (aborted) return; // Finalize transfer - now update mode to local sessions.selectSession(null); update((s) => ({ ...s, mode: "local", remoteSessionId: null, isTransferring: false, })); console.log("[PlaybackMode] Successfully transferred to local"); } catch (error) { if (aborted) { console.log("[PlaybackMode] Transfer was cancelled"); return; } const message = error instanceof Error ? error.message : "Failed to transfer playback"; update((s) => ({ ...s, isTransferring: false, transferError: message, })); console.error("Transfer to local failed:", error); throw error; } finally { currentTransferAbort = null; } } /** * Monitor remote session for disconnection with grace period. * Requires multiple consecutive misses before declaring disconnection * to tolerate transient network hiccups. */ function initializeSessionMonitoring(): void { let consecutiveMisses = 0; const DISCONNECT_THRESHOLD = 3; // ~6s at 2s polling interval selectedSession.subscribe((session) => { const currentState = get({ subscribe }); // If we're in remote mode but session is gone or lost control capability // Don't interfere during an active transfer (we intentionally clear the session) if (currentState.mode === "remote" && currentState.remoteSessionId && !currentState.isTransferring) { if (!session || session.id !== currentState.remoteSessionId || !session.supportsMediaControl) { consecutiveMisses++; console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`); if (consecutiveMisses >= DISCONNECT_THRESHOLD) { console.warn("[PlaybackMode] Remote session lost after sustained disconnection"); consecutiveMisses = 0; update((s) => ({ ...s, mode: "idle", remoteSessionId: null, transferError: "Remote session disconnected", })); } } else { // Session is healthy, reset counter if (consecutiveMisses > 0) { console.log("[PlaybackMode] Remote session recovered after", consecutiveMisses, "misses"); } consecutiveMisses = 0; } } else { consecutiveMisses = 0; } }); } /** * Clear transfer error message */ function clearError(): void { update((s) => ({ ...s, transferError: null })); } /** * Cancel ongoing transfer operation */ function cancelTransfer(): void { if (currentTransferAbort) { console.log("[PlaybackMode] Cancelling transfer"); currentTransferAbort(); } } /** * Disconnect from remote session without transferring playback * This stops controlling the remote device and returns to idle/local state */ async function disconnect(): Promise { console.log("[PlaybackMode] Disconnecting from remote session"); const currentState = get({ subscribe }); if (currentState.mode !== "remote") { console.log("[PlaybackMode] Not in remote mode, nothing to disconnect"); return; } try { // Notify Rust backend to switch to idle mode await invoke("playback_mode_set", { mode: { type: "idle" } }); // Update local state sessions.selectSession(null); update((s) => ({ ...s, mode: "idle", remoteSessionId: null, transferError: null, })); console.log("[PlaybackMode] Successfully disconnected"); } catch (error) { const message = error instanceof Error ? error.message : "Failed to disconnect"; console.error("[PlaybackMode] Disconnect failed:", error); update((s) => ({ ...s, transferError: message, })); throw error; } } // Note: initializeSessionMonitoring() and refreshMode() should be called // manually from +layout.svelte after auth initialization, not automatically // at module load time to avoid race conditions with other Rust commands return { subscribe, setMode, transferToRemote, transferToLocal, disconnect, refresh: refreshMode, initializeSessionMonitoring, clearError, cancelTransfer, }; } export const playbackMode = createPlaybackModeStore(); // Derived stores for convenience export const isRemoteMode = derived(playbackMode, ($mode) => $mode.mode === "remote"); export const isLocalMode = derived(playbackMode, ($mode) => $mode.mode === "local"); export const isIdleMode = derived(playbackMode, ($mode) => $mode.mode === "idle"); export const isTransferring = derived(playbackMode, ($mode) => $mode.isTransferring); export const transferError = derived(playbackMode, ($mode) => $mode.transferError);