Files
jellytau/src/lib/stores/playbackMode.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

470 lines
16 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 { commands, events } from "$lib/api/bindings";
import { sessions, selectedSession } from "./sessions";
import { auth } from "./auth";
import { ticksToSeconds } from "$lib/utils/playbackUnits";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("PlaybackMode");
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 commands.playbackModeGetCurrent()) as RustPlaybackMode;
const remoteSessionId = rustMode.type === "remote" ? rustMode.session_id || null : null;
update((s) => ({
...s,
mode: rustMode.type,
remoteSessionId,
}));
// Keep the selected session aligned so the merged UI stores follow the
// authoritative mode.
sessions.selectSession(remoteSessionId);
} catch (error) {
log.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 | null | undefined,
currentPosition?: number,
): Promise<void> {
log.debug("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 {
// Pass the caller's current local position so the remote resumes where we
// are. The backend can't reliably read this itself: on Linux video plays in
// the HTML5 <video> element and the MPV backend reports 0. A null override
// falls back to the backend position (correct for Linux audio via MPV).
const positionOverride =
currentPosition !== undefined && currentPosition > 0 ? currentPosition : null;
// Rust handles everything - just wait for it to complete
// It includes its own 5-second timeout for track loading
log.debug(
"About to invoke playback_mode_transfer_to_remote with sessionId:",
sessionId,
"position:",
positionOverride,
);
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
log.debug("Invoke completed successfully");
if (aborted) {
log.debug("Transfer was cancelled");
return;
}
// Update local state
sessions.selectSession(sessionId ?? null);
update((s) => ({
...s,
mode: "remote",
remoteSessionId: sessionId ?? null,
isTransferring: false,
}));
log.debug("Successfully transferred to remote");
} catch (error) {
if (aborted) {
log.debug("Transfer was cancelled");
return;
}
const message = error instanceof Error ? error.message : "Failed to transfer playback";
update((s) => ({
...s,
isTransferring: false,
transferError: message,
}));
log.error("Transfer to remote failed:", error);
throw error;
} finally {
currentTransferAbort = null;
// Snap back to whatever the Rust manager actually settled on. If any step
// above threw mid-transfer, the optimistic update may not match reality;
// Rust is authoritative, so reconcile to it.
await refreshMode();
}
}
/**
* 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> {
log.debug("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;
log.debug("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();
// 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();
// Pass the resume position so the backend seeks at load time. Doing the
// seek here (rather than a delayed playerSeek) avoids the race where the
// media isn't loaded yet and the seek is lost, restarting from 0.
await commands.playerPlayTracks(repositoryHandle, {
trackIds: [itemId],
startIndex: 0,
shuffle: false,
startPosition: positionSeconds,
context: {
type: "search",
searchQuery: "",
},
});
if (aborted) return;
// Let Rust handle stopping remote playback
await commands.playbackModeTransferToLocal(itemId, positionTicks);
if (aborted) return;
// Finalize transfer - now update mode to local
sessions.selectSession(null);
update((s) => ({
...s,
mode: "local",
remoteSessionId: null,
isTransferring: false,
}));
log.debug("Successfully transferred to local");
} catch (error) {
if (aborted) {
log.debug("Transfer was cancelled");
return;
}
const message = error instanceof Error ? error.message : "Failed to transfer playback";
update((s) => ({
...s,
isTransferring: false,
transferError: message,
}));
log.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) {
log.warn("Failed to clear transferring flag:", e);
}
currentTransferAbort = null;
// Reconcile to the authoritative Rust mode in case a step above threw and
// left our optimistic state inconsistent (see transferToRemote).
await refreshMode();
}
}
/**
* 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
// The lockscreen Stop button (while casting) asks the native side to
// disconnect from the remote session and resume locally. The remote->local
// transfer must be driven here because it reloads the media item locally,
// which only the frontend can currently do.
events.playerStatusEvent.listen((event) => {
if (event.payload.type === "remote_disconnect_requested") {
const currentState = get({ subscribe });
if (currentState.mode === "remote") {
log.debug("Lockscreen requested disconnect; transferring to local");
transferToLocal().catch((e) => log.error("Lockscreen-triggered transfer failed:", e));
}
return;
}
// The Rust PlaybackModeManager is the single source of truth for routing.
// Reconcile our mirror store to it whenever it changes, so the UI and the
// event filter in playerEvents.ts can't drift and start routing controls to
// the wrong device. We deliberately do NOT reconcile while a transfer is in
// flight: transfers emit intermediate mode changes (and briefly hold the
// transferring flag), and the transfer functions own the final state.
if (event.payload.type === "playback_mode_changed") {
const currentState = get({ subscribe });
if (currentState.isTransferring) {
return;
}
const mode = event.payload.mode as PlaybackMode;
const remoteSessionId = mode === "remote" ? (event.payload.session_id ?? null) : null;
// Ignore no-op re-broadcasts. The backend re-emits on every set_mode, and
// local playback drives set_mode("local") from BOTH the frontend
// (handleStateChanged) and Rust, so the same mode arrives repeatedly. If
// we reconciled unconditionally we'd re-run selectSession(null) on each
// one, deselecting the remote session mid-cast and tripping the
// disconnect-to-idle watchdog (breaking the lockscreen card, remote
// volume, and — via the resulting mode flap — local audio).
if (currentState.mode === mode && currentState.remoteSessionId === remoteSessionId) {
return;
}
log.debug("Backend mode changed →", mode, remoteSessionId);
update((s) => ({ ...s, mode, remoteSessionId }));
// Keep the selected session in step so the merged UI stores follow, but
// only touch the selection when it actually differs — re-selecting the
// same id (or clearing on a non-remote emit that isn't a real change)
// would needlessly churn the session watchdog.
const selected = get(selectedSession);
if ((selected?.id ?? null) !== remoteSessionId) {
sessions.selectSession(remoteSessionId);
}
}
});
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++;
log.warn(`Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
log.warn("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) {
log.debug("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) {
log.debug("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> {
log.debug("Disconnecting from remote session");
const currentState = get({ subscribe });
if (currentState.mode !== "remote") {
log.debug("Not in remote mode, nothing to disconnect");
return;
}
try {
// Notify Rust backend to switch to idle mode
await commands.playbackModeSet({ type: "idle" });
// Update local state
sessions.selectSession(null);
update((s) => ({
...s,
mode: "idle",
remoteSessionId: null,
transferError: null,
}));
log.debug("Successfully disconnected");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to disconnect";
log.error("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);