Fix android playback issue
This commit is contained in:
parent
75014ee00f
commit
6af7f7dcca
229
src/lib/components/player/VideoPlayer.scrubRegression.test.ts
Normal file
229
src/lib/components/player/VideoPlayer.scrubRegression.test.ts
Normal file
@ -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<string, (event: any) => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,6 +13,7 @@
|
|||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
|
import { playbackPosition } from "$lib/stores/player";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: MediaItem | null;
|
media: MediaItem | null;
|
||||||
@ -88,6 +89,16 @@
|
|||||||
|
|
||||||
// Backend info from Rust (Rust decides which backend to use based on platform)
|
// 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 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 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 didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
|
||||||
let swipeType = $state<"brightness" | null>(null);
|
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
|
// Set up HLS.js for HLS streams
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
|
if (!useHtml5Element || !videoElement || !currentStreamUrl) {
|
||||||
@ -465,6 +487,7 @@
|
|||||||
|
|
||||||
// Rust tells us which backend it's using
|
// Rust tells us which backend it's using
|
||||||
useHtml5Element = response.useHtml5Element;
|
useHtml5Element = response.useHtml5Element;
|
||||||
|
backendChosen = true;
|
||||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||||
|
|
||||||
// If using HTML5 element for non-transcoded content, stop the backend player
|
// If using HTML5 element for non-transcoded content, stop the backend player
|
||||||
@ -486,38 +509,48 @@
|
|||||||
if (!useHtml5Element) {
|
if (!useHtml5Element) {
|
||||||
// Using native backend, subscribe to player events
|
// Using native backend, subscribe to player events
|
||||||
didStartNativePlayback = true; // Track that we started native playback
|
didStartNativePlayback = true; // Track that we started native playback
|
||||||
const unlisten1 = await listen("player://position-update", (event: any) => {
|
isPlaying = (response.state?.kind ?? response.state) === "playing";
|
||||||
if (!isDraggingSeekBar) {
|
// Cleanup happens in the component's top-level onDestroy. Calling
|
||||||
currentTime = event.payload.position;
|
// 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).
|
||||||
const unlisten2 = await listen("player://state-changed", (event: any) => {
|
nativeUnlisteners.push(
|
||||||
isPlaying = event.payload.state === "playing";
|
await listen("player://position-update", (event: any) => {
|
||||||
});
|
if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
|
||||||
|
currentTime = event.payload.position;
|
||||||
// Clean up listeners on destroy
|
}
|
||||||
onDestroy(() => {
|
})
|
||||||
unlisten1();
|
);
|
||||||
unlisten2();
|
nativeUnlisteners.push(
|
||||||
});
|
await listen("player://state-changed", (event: any) => {
|
||||||
|
isPlaying = event.payload.state === "playing";
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[VideoPlayer] Failed to initialize player:", err);
|
console.error("[VideoPlayer] Failed to initialize player:", err);
|
||||||
// Fallback to HTML5 on error
|
if (backendChosen) {
|
||||||
useHtml5Element = true;
|
// The backend already accepted the item; a later error (e.g. event
|
||||||
|
// subscription) must not silently switch the seek/controls path to
|
||||||
// For non-transcoded content, try to stop any backend player that might have started
|
// HTML5 while the native backend keeps playing.
|
||||||
if (!needsTranscoding) {
|
console.warn("[VideoPlayer] Backend already initialized - keeping native mode despite error");
|
||||||
try {
|
|
||||||
await commands.playerStop();
|
|
||||||
didStopBackendEarly = true;
|
|
||||||
} catch (stopErr) {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// For transcoded content, keep backend for seeking
|
// Fallback to HTML5 on error
|
||||||
didStartNativePlayback = true;
|
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);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove native backend event listeners
|
||||||
|
for (const unlisten of nativeUnlisteners) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
nativeUnlisteners = [];
|
||||||
|
|
||||||
// Clean up HLS.js instance - prevent dual audio on unmount
|
// Clean up HLS.js instance - prevent dual audio on unmount
|
||||||
if (hls) {
|
if (hls) {
|
||||||
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
|
||||||
@ -924,10 +963,12 @@
|
|||||||
// For native backend, the backend handles everything internally
|
// For native backend, the backend handles everything internally
|
||||||
if (!useHtml5Element) {
|
if (!useHtml5Element) {
|
||||||
// Backend already stopped, reloaded, and seeked if needed
|
// Backend already stopped, reloaded, and seeked if needed
|
||||||
|
lastNativeSeekAt = Date.now();
|
||||||
currentTime = response.position ?? targetTime;
|
currentTime = response.position ?? targetTime;
|
||||||
if (response.strategy === "reloadStream") {
|
if (response.strategy === "reloadStream") {
|
||||||
seekOffset = response.seekOffset ?? targetTime;
|
// Serde keeps these fields snake_case (only the "strategy" tag is camelCase)
|
||||||
currentStreamUrl = response.newUrl ?? currentStreamUrl;
|
seekOffset = response.seek_offset ?? targetTime;
|
||||||
|
currentStreamUrl = response.new_url ?? currentStreamUrl;
|
||||||
} else {
|
} else {
|
||||||
seekOffset = 0;
|
seekOffset = 0;
|
||||||
}
|
}
|
||||||
@ -969,8 +1010,9 @@
|
|||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Update stream URL (this will trigger $effect to create new HLS instance)
|
// Update stream URL (this will trigger $effect to create new HLS instance)
|
||||||
seekOffset = response.seekOffset ?? targetTime;
|
// Serde keeps these fields snake_case (only the "strategy" tag is camelCase)
|
||||||
currentStreamUrl = response.newUrl ?? currentStreamUrl;
|
seekOffset = response.seek_offset ?? targetTime;
|
||||||
|
currentStreamUrl = response.new_url ?? currentStreamUrl;
|
||||||
currentTime = targetTime;
|
currentTime = targetTime;
|
||||||
|
|
||||||
// Wait for video to be ready
|
// Wait for video to be ready
|
||||||
@ -1238,7 +1280,8 @@
|
|||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Update stream URL (this will trigger $effect to create new HLS instance)
|
// 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!;
|
seekOffset = response.position!;
|
||||||
|
|
||||||
// Wait for video to be ready
|
// Wait for video to be ready
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user