jellytau/src/lib/stores/playbackMode.ts
Duncan Tourolle e3797f32ca
Some checks failed
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
many changes
2026-02-14 00:09:47 +01:00

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);