diff --git a/src/lib/components/player/VideoPlayer.scrubRegression.test.ts b/src/lib/components/player/VideoPlayer.scrubRegression.test.ts new file mode 100644 index 0000000..6cfc0cb --- /dev/null +++ b/src/lib/components/player/VideoPlayer.scrubRegression.test.ts @@ -0,0 +1,229 @@ +/** + * VideoPlayer scrub regression tests (Android native backend path) + * + * Reproduces the reported bug: with a sleep timer active, scrubbing the + * video seek bar "seeks, then jumps back to the old position". + * + * These tests mount the REAL VideoPlayer in native-backend mode (what + * Android uses: playerPlayItem responds useHtml5Element=false), scrub the + * seek bar, then drive the same backend signals the app receives at + * runtime (position updates, sleep-timer ticks) and assert the seek bar + * does not snap back. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ---- Mocks (must precede component import) -------------------------------- + +// Capture raw-channel listeners VideoPlayer registers in native mode +// ("player://position-update", "player://state-changed"). +const channelHandlers: Record void> = {}; +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async (channel: string, handler: any) => { + channelHandlers[channel] = handler; + return () => { + delete channelHandlers[channel]; + }; + }), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const playerPlayItem = vi.fn(async () => ({ + useHtml5Element: false, + backend: "exoplayer", + state: { kind: "playing" }, +})); +const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({ + strategy: "native", + position, +})); +const playerStop = vi.fn(async () => ({})); +const playerToggle = vi.fn(async () => ({ state: "playing" })); +const playerSetSleepTimer = vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 })); +const playerCancelSleepTimer = vi.fn(async () => ({ mode: { kind: "off" }, remainingSeconds: 0 })); + +vi.mock("$lib/api/bindings", () => ({ + commands: { + playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])), + playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])), + playerStop: (...a: any[]) => playerStop(...(a as [])), + playerToggle: (...a: any[]) => playerToggle(...(a as [])), + playerSetSleepTimer: (...a: any[]) => playerSetSleepTimer(...(a as [any])), + playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])), + playerSetSubtitleTrack: vi.fn(async () => ({})), + playerSwitchAudioTrack: vi.fn(async () => ({})), + storageGetSeriesAudioPreference: vi.fn(async () => null), + storageSaveSeriesAudioPreference: vi.fn(async () => ({})), + }, + events: { + playerStatusEvent: { listen: vi.fn(async () => () => {}) }, + }, +})); + +vi.mock("$lib/stores/auth", () => ({ + auth: { + getUserId: () => "user-1", + getRepository: () => ({ + getHandle: () => "repo-1", + getSubtitleUrl: async () => "", + jrayActorsAt: async () => [], + }), + }, +})); + +vi.mock("$app/navigation", () => ({ + goto: vi.fn(), +})); + +// Use the REAL sleepTimer store module so timer activation flows exactly as +// in production (playerEvents.ts writes to it on every backend tick). + +import { render, fireEvent, waitFor } from "@testing-library/svelte"; +import { tick } from "svelte"; +import VideoPlayer from "./VideoPlayer.svelte"; +import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; +import type { MediaItem } from "$lib/api/types"; + +function makeEpisode(): MediaItem { + return { + id: "ep1", + name: "Episode 1", + type: "Episode", + runTimeTicks: 24 * 60 * 10_000_000, // 24 min + } as MediaItem; +} + +/** Simulate one backend sleep-timer tick, exactly as playerEvents.ts does. */ +function sleepTimerTick(remaining = 2) { + sleepTimer.set({ + mode: { kind: "episodes", remaining }, + remainingSeconds: 0, + }); +} + +async function mountNativePlayer() { + const utils = render(VideoPlayer, { + props: { + media: makeEpisode(), + streamUrl: "http://server/videos/ep1/master.m3u8", + mediaSourceId: "src-1", + needsTranscoding: false, + onClose: vi.fn(), + }, + }); + + // Wait for onMount init: backend chosen (native), raw listeners registered. + await waitFor(() => expect(playerPlayItem).toHaveBeenCalled()); + await waitFor(() => + expect(channelHandlers["player://position-update"]).toBeDefined() + ); + + // Backend reports playing at 300s. + channelHandlers["player://state-changed"]({ payload: { state: "playing" } }); + channelHandlers["player://position-update"]({ + payload: { position: 300, duration: 1440 }, + }); + await tick(); + + const slider = utils.container.querySelector( + 'input[type="range"]' + ) as HTMLInputElement; + expect(slider).not.toBeNull(); + expect(parseFloat(slider.value)).toBeCloseTo(300); + return { ...utils, slider }; +} + +/** Scrub the seek bar to `target` seconds like a user drag. */ +async function scrubTo(slider: HTMLInputElement, target: number) { + await fireEvent.mouseDown(slider); + slider.value = String(target); + await fireEvent.input(slider); + await fireEvent.change(slider); + await fireEvent.mouseUp(slider); + await tick(); +} + +describe("VideoPlayer scrubbing with active sleep timer (native backend)", () => { + beforeEach(() => { + vi.clearAllMocks(); + for (const key of Object.keys(channelHandlers)) delete channelHandlers[key]; + sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 }); + sleepTimerExpiredSignal.set(0); + }); + + it("scrubbing without a timer issues a native seek and keeps the new position", async () => { + const { slider } = await mountNativePlayer(); + + await scrubTo(slider, 600); + + await waitFor(() => + expect(playerSeekVideo).toHaveBeenCalledWith( + "repo-1", + 600, + "src-1", + null, + false + ) + ); + expect(parseFloat(slider.value)).toBeCloseTo(600); + }); + + it("scrubbing still works after enabling an episodes sleep timer", async () => { + const { slider } = await mountNativePlayer(); + + // Enable "2 more episodes" timer; backend then ticks every second. + sleepTimerTick(2); + await tick(); + sleepTimerTick(2); + await tick(); + + await scrubTo(slider, 600); + + await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled()); + expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, false); + expect(parseFloat(slider.value)).toBeCloseTo(600); + + // A second scrub must also work. + await scrubTo(slider, 900); + await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2)); + expect(parseFloat(slider.value)).toBeCloseTo(900); + }); + + it("REGRESSION: a stale backend position tick right after scrubbing must not snap the bar back", async () => { + const { slider } = await mountNativePlayer(); + + sleepTimerTick(2); + await tick(); + + await scrubTo(slider, 600); + await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled()); + expect(parseFloat(slider.value)).toBeCloseTo(600); + + // ExoPlayer's position poller runs on its own cadence: a tick captured + // just before the seek landed arrives now, carrying the OLD position. + channelHandlers["player://position-update"]({ + payload: { position: 301, duration: 1440 }, + }); + // Plus the per-second sleep-timer tick. + sleepTimerTick(2); + await tick(); + + // The bar must hold the seek target, not snap back to the stale position. + expect(parseFloat(slider.value)).toBeCloseTo(600); + }); + + it("sleep-timer ticks alone never move the seek bar", async () => { + const { slider } = await mountNativePlayer(); + + for (let i = 0; i < 5; i++) { + sleepTimerTick(2); + await tick(); + } + + expect(parseFloat(slider.value)).toBeCloseTo(300); + expect(playerSeekVideo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 1cb88eb..237b1d2 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -13,6 +13,7 @@ import SleepTimerIndicator from "./SleepTimerIndicator.svelte"; import CachedImage from "../common/CachedImage.svelte"; import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer"; + import { playbackPosition } from "$lib/stores/player"; interface Props { media: MediaItem | null; @@ -88,6 +89,16 @@ // Backend info from Rust (Rust decides which backend to use based on platform) let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend + let backendChosen = false; // playerPlayItem succeeded and told us which backend to use + let nativeUnlisteners: Array<() => void> = []; // raw-channel listeners for native backend mode + // Position updates captured before a native seek can land after it and snap + // the bar back; suppress backend position feeds briefly after each seek + // (same idea as the MPV backend's last_seek_time suppression). + let lastNativeSeekAt = 0; + const NATIVE_SEEK_SETTLE_MS = 1500; + function nativeSeekSettling(): boolean { + return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS; + } let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount) let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content let swipeType = $state<"brightness" | null>(null); @@ -224,6 +235,17 @@ } }); + // Native backend (Android ExoPlayer): drive the seek bar from the player + // store, which is fed by the backend's PositionUpdate events. The legacy + // "player://position-update" raw channel was never emitted by the backend, + // so without this the bar only moves when the user scrubs. + $effect(() => { + const position = $playbackPosition; + if (!useHtml5Element && !isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) { + currentTime = position; + } + }); + // Set up HLS.js for HLS streams $effect(() => { if (!useHtml5Element || !videoElement || !currentStreamUrl) { @@ -465,6 +487,7 @@ // Rust tells us which backend it's using useHtml5Element = response.useHtml5Element; + backendChosen = true; console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`); // If using HTML5 element for non-transcoded content, stop the backend player @@ -486,38 +509,48 @@ if (!useHtml5Element) { // Using native backend, subscribe to player events didStartNativePlayback = true; // Track that we started native playback - const unlisten1 = await listen("player://position-update", (event: any) => { - if (!isDraggingSeekBar) { - currentTime = event.payload.position; - } - }); - - const unlisten2 = await listen("player://state-changed", (event: any) => { - isPlaying = event.payload.state === "playing"; - }); - - // Clean up listeners on destroy - onDestroy(() => { - unlisten1(); - unlisten2(); - }); + isPlaying = (response.state?.kind ?? response.state) === "playing"; + // Cleanup happens in the component's top-level onDestroy. Calling + // onDestroy() here — after an await — throws lifecycle_outside_component, + // which the catch below used to misread as an init failure: it flipped + // useHtml5Element to true, so every seek went down the HTML5 path and + // never reached ExoPlayer (the video "seeked" then snapped back). + nativeUnlisteners.push( + await listen("player://position-update", (event: any) => { + if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) { + currentTime = event.payload.position; + } + }) + ); + nativeUnlisteners.push( + await listen("player://state-changed", (event: any) => { + isPlaying = event.payload.state === "playing"; + }) + ); } } catch (err) { console.error("[VideoPlayer] Failed to initialize player:", err); - // Fallback to HTML5 on error - useHtml5Element = true; - - // For non-transcoded content, try to stop any backend player that might have started - if (!needsTranscoding) { - try { - await commands.playerStop(); - didStopBackendEarly = true; - } catch (stopErr) { - // Ignore errors when stopping - } + if (backendChosen) { + // The backend already accepted the item; a later error (e.g. event + // subscription) must not silently switch the seek/controls path to + // HTML5 while the native backend keeps playing. + console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error"); } else { - // For transcoded content, keep backend for seeking - didStartNativePlayback = true; + // Fallback to HTML5 on error + useHtml5Element = true; + + // For non-transcoded content, try to stop any backend player that might have started + if (!needsTranscoding) { + try { + await commands.playerStop(); + didStopBackendEarly = true; + } catch (stopErr) { + // Ignore errors when stopping + } + } else { + // For transcoded content, keep backend for seeking + didStartNativePlayback = true; + } } } } @@ -568,6 +601,12 @@ clearInterval(debugLogInterval); } + // Remove native backend event listeners + for (const unlisten of nativeUnlisteners) { + unlisten(); + } + nativeUnlisteners = []; + // Clean up HLS.js instance - prevent dual audio on unmount if (hls) { console.log("[VideoPlayer] Destroying HLS.js instance on unmount"); @@ -924,10 +963,12 @@ // For native backend, the backend handles everything internally if (!useHtml5Element) { // Backend already stopped, reloaded, and seeked if needed + lastNativeSeekAt = Date.now(); currentTime = response.position ?? targetTime; if (response.strategy === "reloadStream") { - seekOffset = response.seekOffset ?? targetTime; - currentStreamUrl = response.newUrl ?? currentStreamUrl; + // Serde keeps these fields snake_case (only the "strategy" tag is camelCase) + seekOffset = response.seek_offset ?? targetTime; + currentStreamUrl = response.new_url ?? currentStreamUrl; } else { seekOffset = 0; } @@ -969,8 +1010,9 @@ await new Promise(resolve => setTimeout(resolve, 100)); // Update stream URL (this will trigger $effect to create new HLS instance) - seekOffset = response.seekOffset ?? targetTime; - currentStreamUrl = response.newUrl ?? currentStreamUrl; + // Serde keeps these fields snake_case (only the "strategy" tag is camelCase) + seekOffset = response.seek_offset ?? targetTime; + currentStreamUrl = response.new_url ?? currentStreamUrl; currentTime = targetTime; // Wait for video to be ready @@ -1238,7 +1280,8 @@ await new Promise(resolve => setTimeout(resolve, 100)); // Update stream URL (this will trigger $effect to create new HLS instance) - currentStreamUrl = response.newUrl!; + // Serde keeps new_url snake_case (only the "strategy" tag is camelCase) + currentStreamUrl = response.new_url!; seekOffset = response.position!; // Wait for video to be ready