TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
463 lines
16 KiB
TypeScript
463 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);
|