374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
/**
|
|
* 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<PlaybackModeState>(initialState);
|
|
|
|
// Track ongoing transfer promise to allow cancellation
|
|
let currentTransferAbort: (() => void) | null = null;
|
|
|
|
/**
|
|
* Refresh mode from Rust backend
|
|
*/
|
|
async function refreshMode(): Promise<void> {
|
|
try {
|
|
const rustMode = await invoke<RustPlaybackMode>("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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|