First working POC
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||
* @req: IR-012 - Jellyfin Sessions API for remote playback control
|
||||
* @req: DR-037 - Remote session browser and control UI
|
||||
*/
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user