jellytau/src/lib/stores/sessions.ts
Duncan Tourolle d01c2aab9f
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Migrate all IPC call sites to typed tauri-specta commands.*
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.

- Register repository_get_subtitle_url and repository_get_video_download_url
  in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
  ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
  - VideoDownloadButton passed an un-awaited Promise as the stream URL.
  - setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
  enforce what they checked.

svelte-check: 0 errors. vitest: green. cargo test --lib: green.
2026-06-21 08:47:04 +02:00

258 lines
7.2 KiB
TypeScript

// Remote sessions store for controlling playback on other Jellyfin clients
// TRACES: UR-010 | DR-037
import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import type { Session } from "$lib/api/types";
interface SessionsState {
sessions: Session[];
selectedSessionId: string | null;
isLoading: boolean;
error: string | null;
lastUpdated: Date | null;
}
interface PlayerStatusEvent {
type: string;
sessions?: Session[];
}
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
listen<PlayerStatusEvent>("player-event", (event) => {
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
event.payload.sessions.forEach((s, i) => {
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
});
update((s) => ({
...s,
sessions: event.payload.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();
console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`);
sessions.forEach((s, i) => {
console.log(`[Sessions] 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,
}));
console.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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.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) {
console.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> {
console.log("[SESSIONS] ========== playOnSession called ==========");
console.log("[SESSIONS] sessionId:", sessionId);
console.log("[SESSIONS] itemIds array:", itemIds);
console.log("[SESSIONS] itemIds.length:", itemIds.length);
console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds));
console.log("[SESSIONS] startIndex:", startIndex);
console.log("[SESSIONS] About to call commands.remotePlayOnSession");
try {
// Use Rust player's Jellyfin client for remote playback
const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
console.log("[SESSIONS] invoke succeeded, result:", result);
await refresh();
} catch (error) {
console.error("[SESSIONS] 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);
console.log(`[Sessions] Filtering ${$sessions.sessions.length} total sessions, ${controllable.length} are controllable`);
$sessions.sessions.forEach((s, i) => {
const status = s.supportsRemoteControl ? "✓ CONTROLLABLE" : "✗ NOT CONTROLLABLE";
console.log(`[Sessions] ${status}: ${s.deviceName} (id=${s.id}, supportsRemoteControl=${s.supportsRemoteControl})`);
});
return controllable;
}
);