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.
256 lines
7.0 KiB
TypeScript
256 lines
7.0 KiB
TypeScript
// Remote sessions store for controlling playback on other Jellyfin clients
|
|
// TRACES: UR-010 | DR-037
|
|
|
|
import { writable, derived } from "svelte/store";
|
|
import { commands, events } from "$lib/api/bindings";
|
|
import type { Session } from "$lib/api/types";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("Sessions");
|
|
|
|
interface SessionsState {
|
|
sessions: Session[];
|
|
selectedSessionId: string | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
lastUpdated: Date | null;
|
|
}
|
|
|
|
function createSessionsStore() {
|
|
const initialState: SessionsState = {
|
|
sessions: [],
|
|
selectedSessionId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
lastUpdated: null,
|
|
};
|
|
|
|
const { subscribe, update } = writable<SessionsState>(initialState);
|
|
|
|
// Listen for session updates from Rust backend (generated tauri-specta event)
|
|
events.playerStatusEvent.listen((event) => {
|
|
if (event.payload.type === "sessions_updated") {
|
|
const sessions = event.payload.sessions as unknown as Session[];
|
|
log.debug(`Received ${sessions.length} sessions from backend`);
|
|
sessions.forEach((s, i) => {
|
|
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
|
});
|
|
update((s) => ({
|
|
...s,
|
|
sessions,
|
|
lastUpdated: new Date(),
|
|
error: null,
|
|
}));
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Manually fetch sessions from backend (for refresh button)
|
|
*/
|
|
async function refresh(): Promise<void> {
|
|
try {
|
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
|
|
|
const sessions = await commands.sessionsPollNow();
|
|
|
|
log.debug(`Manual refresh returned ${sessions.length} sessions`);
|
|
sessions.forEach((s, i) => {
|
|
log.debug(`Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
|
});
|
|
|
|
update((s) => ({
|
|
...s,
|
|
sessions,
|
|
isLoading: false,
|
|
lastUpdated: new Date(),
|
|
error: null,
|
|
}));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to fetch sessions";
|
|
update((s) => ({
|
|
...s,
|
|
isLoading: false,
|
|
error: message,
|
|
}));
|
|
log.error("Failed to fetch sessions:", error);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Select a session for control
|
|
*/
|
|
function selectSession(sessionId: string | null): void {
|
|
update((s) => ({ ...s, selectedSessionId: sessionId }));
|
|
}
|
|
|
|
/**
|
|
* Send play/pause toggle command
|
|
*/
|
|
async function sendPlayPause(sessionId: string | null | undefined): Promise<void> {
|
|
try {
|
|
await commands.remoteSendCommand(sessionId ?? "", "PlayPause");
|
|
// Refresh after command to get updated state
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to send play/pause command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send stop command
|
|
*/
|
|
async function sendStop(sessionId: string | null | undefined): Promise<void> {
|
|
try {
|
|
await commands.remoteSendCommand(sessionId ?? "", "Stop");
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to send stop command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send next track command
|
|
*/
|
|
async function sendNext(sessionId: string | null | undefined): Promise<void> {
|
|
try {
|
|
await commands.remoteSendCommand(sessionId ?? "", "NextTrack");
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to send next track command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send previous track command
|
|
*/
|
|
async function sendPrevious(sessionId: string | null | undefined): Promise<void> {
|
|
try {
|
|
await commands.remoteSendCommand(sessionId ?? "", "PreviousTrack");
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to send previous track command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Seek to position (in ticks)
|
|
*/
|
|
async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
|
|
try {
|
|
await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
|
|
// Don't refresh immediately for seek to avoid UI lag
|
|
} catch (error) {
|
|
log.error("Failed to send seek command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set volume (0-100)
|
|
*/
|
|
async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> {
|
|
try {
|
|
await commands.remoteSessionSetVolume(sessionId ?? "", volume);
|
|
// Don't refresh immediately for volume to avoid UI lag
|
|
} catch (error) {
|
|
log.error("Failed to send volume command:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle mute
|
|
*/
|
|
async function sendToggleMute(sessionId: string | null | undefined): Promise<void> {
|
|
try {
|
|
await commands.remoteSendCommand(sessionId ?? "", "ToggleMute");
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to toggle mute:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play item(s) on remote session
|
|
*/
|
|
async function playOnSession(
|
|
sessionId: string | null | undefined,
|
|
itemIds: string[],
|
|
startIndex = 0
|
|
): Promise<void> {
|
|
log.debug("========== playOnSession called ==========");
|
|
log.debug("sessionId:", sessionId);
|
|
log.debug("itemIds array:", itemIds);
|
|
log.debug("itemIds.length:", itemIds.length);
|
|
log.debug("itemIds JSON:", JSON.stringify(itemIds));
|
|
log.debug("startIndex:", startIndex);
|
|
log.debug("About to call commands.remotePlayOnSession");
|
|
try {
|
|
// Use Rust player's Jellyfin client for remote playback
|
|
const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
|
|
log.debug("invoke succeeded, result:", result);
|
|
await refresh();
|
|
} catch (error) {
|
|
log.error("Failed to play on session:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return {
|
|
subscribe,
|
|
refresh,
|
|
selectSession,
|
|
sendPlayPause,
|
|
sendStop,
|
|
sendNext,
|
|
sendPrevious,
|
|
sendSeek,
|
|
sendVolume,
|
|
sendToggleMute,
|
|
playOnSession,
|
|
};
|
|
}
|
|
|
|
export const sessions = createSessionsStore();
|
|
|
|
// Derived stores
|
|
|
|
/**
|
|
* Sessions that are currently playing media
|
|
*/
|
|
export const activeSessions = derived(
|
|
sessions,
|
|
($sessions) => $sessions.sessions.filter((s) => s.nowPlayingItem !== null)
|
|
);
|
|
|
|
/**
|
|
* Currently selected session
|
|
*/
|
|
export const selectedSession = derived(
|
|
sessions,
|
|
($sessions) =>
|
|
$sessions.sessions.find((s) => s.id === $sessions.selectedSessionId) ?? null
|
|
);
|
|
|
|
/**
|
|
* Controllable sessions (support remote control)
|
|
*/
|
|
export const controllableSessions = derived(
|
|
sessions,
|
|
($sessions) => {
|
|
const controllable = $sessions.sessions.filter((s) => s.supportsRemoteControl);
|
|
log.debug(`Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
|
|
$sessions.sessions.forEach((s, i) => {
|
|
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
|
|
log.debug(` ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
|
|
});
|
|
return controllable;
|
|
}
|
|
);
|