jellytau/src/lib/stores/playbackMode.ts
Duncan Tourolle 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
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>
2026-06-27 23:55:26 +02:00

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);