The lockscreen controls drifted out of sync, especially while casting, and couldn't control remote playback. Two media sessions were competing (a Media3 MediaSession driving transport vs a MediaSessionCompat driving the notification), position was only pushed on play/pause so the scrubber froze mid-track, and remote mode showed stale local metadata with dead buttons. - Make MediaSessionCompat the single source of truth; route all transport commands (both the Compat callback and the Media3 wrappedPlayer) through Rust via nativeOnMediaCommand instead of touching ExoPlayer directly. - Push position on every 250ms tick via a lightweight updatePlaybackPosition, and report 0.0 playback speed when paused so Android stops extrapolating. - Mirror the remote session's now-playing onto the lockscreen from the native session poller (works while the screen is locked, unlike WebView timers) via a new player::update_lockscreen_metadata JNI bridge. - Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/ prev/seek to the remote Jellyfin session; Stop while casting emits RemoteDisconnectRequested, which the frontend handles by transferring to local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
407 lines
13 KiB
TypeScript
407 lines
13 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";
|
|
|
|
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;
|
|
|
|
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 | null | undefined,
|
|
currentPosition?: number,
|
|
): 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 {
|
|
// 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
|
|
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId, "position:", positionOverride);
|
|
await commands.playbackModeTransferToRemote(sessionId ?? "", positionOverride);
|
|
console.log("[PlaybackMode] Invoke completed successfully");
|
|
|
|
if (aborted) {
|
|
console.log("[PlaybackMode] Transfer was cancelled");
|
|
return;
|
|
}
|
|
|
|
// Update local state
|
|
sessions.selectSession(sessionId ?? null);
|
|
update((s) => ({
|
|
...s,
|
|
mode: "remote",
|
|
remoteSessionId: sessionId ?? null,
|
|
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;
|
|
|
|
// 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,
|
|
}));
|
|
|
|
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 {
|
|
// 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) {
|
|
console.warn("[PlaybackMode] Failed to clear transferring flag:", e);
|
|
}
|
|
currentTransferAbort = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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") {
|
|
console.log("[PlaybackMode] Lockscreen requested disconnect; transferring to local");
|
|
transferToLocal().catch((e) =>
|
|
console.error("[PlaybackMode] Lockscreen-triggered transfer failed:", e),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
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++;
|
|
console.warn(`[PlaybackMode] Remote session miss ${consecutiveMisses}/${DISCONNECT_THRESHOLD}`);
|
|
|
|
if (consecutiveMisses >= DISCONNECT_THRESHOLD) {
|
|
console.warn("[PlaybackMode] 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) {
|
|
console.log("[PlaybackMode] 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) {
|
|
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 commands.playbackModeSet({ type: "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);
|