/** * 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; // TODO: After Phase 3 (repository migration), this will be handled by Rust // For now, we need to fetch playback info and start local playback from TypeScript // Get repository to fetch playback info const repository = auth.getRepository(); const playbackInfo = await repository.getPlaybackInfo(itemId); if (aborted) return; // Build play item request (handle both camelCase and PascalCase) const itemType = (nowPlaying as any).type || (nowPlaying as any).Type; const artists = (nowPlaying as any).artists || (nowPlaying as any).Artists; const albumName = (nowPlaying as any).albumName || (nowPlaying as any).AlbumName; const runTimeTicks = (nowPlaying as any).runTimeTicks || (nowPlaying as any).RunTimeTicks; const primaryImageTag = (nowPlaying as any).primaryImageTag || (nowPlaying as any).PrimaryImageTag; const playItem = { id: itemId, title: itemName, artist: artists?.[0], album: albumName, duration: runTimeTicks ? ticksToSeconds(runTimeTicks) : undefined, artworkUrl: repository.getImageUrl(itemId, "Primary", { tag: primaryImageTag, }), mediaType: itemType === "Audio" ? "audio" : "video", streamUrl: playbackInfo.streamUrl, jellyfinItemId: itemId, }; // Start local playback (events allowed through because isTransferring=true) await invoke("player_play_item", { item: playItem }); 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 */ function initializeSessionMonitoring(): void { // Subscribe to session changes 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) { console.warn("[PlaybackMode] Remote session lost or disconnected"); update((s) => ({ ...s, mode: "idle", remoteSessionId: null, transferError: "Remote session disconnected", })); } } }); } /** * 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: "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);