Playback position now crosses the IPC boundary in milliseconds. Ticks survive only inside Rust (DB storage, Jellyfin API) and at the genuine remote-session boundary (session seek / transfer / RemoteControls). Rust command signatures (ms in, converted to ticks internally): - storage_update_playback_progress / _context: position_ms - repository_report_playback_start / _progress / _stopped: position_ms - PlaybackProgress.position_ticks -> position_ms (converted in the query) Frontend: - playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume: seconds*1000 / durationMs/1000 instead of tick math. - repository-client + syncService param names -> positionMs. - Tests updated to ms fixtures/assertions. Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks, sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the remote Jellyfin session API. Rust 456, frontend 644, check clean.
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
/**
|
|
* Player Events Service — regression tests
|
|
*
|
|
* These tests intentionally use the REAL player store (and its real derived
|
|
* stores), unlike playerEvents.test.ts which mocks the store away. The bugs
|
|
* covered here live in the interaction between the event handler and the
|
|
* store's position/duration fields, so a mocked store cannot catch them.
|
|
*
|
|
* TRACES: UR-005 | DR-001
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { get, writable } from "svelte/store";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import type { PlayerStatusEvent } from "$lib/api/bindings";
|
|
|
|
// Capture the handler that initPlayerEvents registers so we can drive events
|
|
// directly, exactly as the Tauri event bridge would.
|
|
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;
|
|
|
|
vi.mock("@tauri-apps/api/event", () => ({
|
|
listen: vi.fn(async (_event: string, handler: any) => {
|
|
registeredHandler = handler;
|
|
return () => {};
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn(),
|
|
}));
|
|
|
|
// The current queue item is what handleStateChanged seeds playing/paused state
|
|
// from. Back it with a writable so each test can install its own item.
|
|
const currentQueueItemStore = writable<MediaItem | null>(null);
|
|
vi.mock("$lib/stores/queue", () => ({
|
|
queue: { subscribe: vi.fn() },
|
|
currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
|
|
}));
|
|
|
|
// Keep the player in local mode so events aren't skipped as remote.
|
|
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
|
|
// imports `isRemoteMode` from the same module — provide both.
|
|
vi.mock("$lib/stores/playbackMode", () => ({
|
|
playbackMode: {
|
|
setMode: vi.fn(),
|
|
initializeSessionMonitoring: vi.fn(),
|
|
subscribe: (run: any) => {
|
|
run({ mode: "local", isTransferring: false });
|
|
return () => {};
|
|
},
|
|
},
|
|
isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
|
|
}));
|
|
|
|
// Remote-mode merged stores in player.ts read this; stub as no remote session.
|
|
vi.mock("$lib/stores/sessions", () => ({
|
|
selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
|
|
}));
|
|
|
|
vi.mock("$lib/stores/sleepTimer", () => ({
|
|
sleepTimer: { set: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("$lib/stores/nextEpisode", () => ({
|
|
nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("$lib/services/preload", () => ({
|
|
preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
|
|
return {
|
|
id: "track-1",
|
|
name: "Test Track",
|
|
kind: "track",
|
|
durationMs: null,
|
|
...overrides,
|
|
} as MediaItem;
|
|
}
|
|
|
|
async function fire(event: PlayerStatusEvent): Promise<void> {
|
|
if (!registeredHandler) throw new Error("handler not registered");
|
|
await registeredHandler({ payload: event });
|
|
// Let any async work inside the handler settle.
|
|
await Promise.resolve();
|
|
}
|
|
|
|
describe("Player Events — pause must not zero the slider duration", () => {
|
|
beforeEach(async () => {
|
|
// initPlayerEvents is a singleton; reset it so each test re-registers its
|
|
// own handler and starts from idle player state.
|
|
const { cleanupPlayerEvents } = await import("./playerEvents");
|
|
const { player } = await import("$lib/stores/player");
|
|
cleanupPlayerEvents();
|
|
player.setIdle();
|
|
vi.clearAllMocks();
|
|
registeredHandler = null;
|
|
currentQueueItemStore.set(null);
|
|
});
|
|
|
|
it("preserves the live duration across pause when runTimeTicks is missing", async () => {
|
|
// runTimeTicks is null — the previous code recomputed duration as 0 here,
|
|
// which collapsed the slider's max and snapped the thumb to the start.
|
|
const item = makeItem({ durationMs: null });
|
|
currentQueueItemStore.set(item);
|
|
|
|
const { initPlayerEvents } = await import("./playerEvents");
|
|
const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");
|
|
|
|
await initPlayerEvents();
|
|
|
|
// 1. Track starts playing.
|
|
await fire({ type: "state_changed", state: "playing", media_id: item.id });
|
|
// 2. Backend reports the real duration once media is loaded / position ticks.
|
|
await fire({ type: "position_update", position: 42, duration: 180 });
|
|
|
|
expect(get(playbackPosition)).toBe(42);
|
|
expect(get(playbackDuration)).toBe(180);
|
|
|
|
// 3. User pauses. Position AND duration must survive — duration is what
|
|
// drives the slider's max, so a 0 here is what caused the regression.
|
|
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
|
|
|
expect(get(playbackPosition)).toBe(42);
|
|
expect(get(playbackDuration)).toBe(180);
|
|
|
|
// Sanity: the store is genuinely paused, not reset to idle/loading.
|
|
expect(get(player).state.kind).toBe("paused");
|
|
});
|
|
|
|
it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
|
|
// 70 seconds = 70_000 ms.
|
|
const item = makeItem({ durationMs: 70_000 });
|
|
currentQueueItemStore.set(item);
|
|
|
|
const { initPlayerEvents } = await import("./playerEvents");
|
|
const { playbackDuration } = await import("$lib/stores/player");
|
|
|
|
await initPlayerEvents();
|
|
|
|
// No position_update yet, so there is no live duration to prefer.
|
|
await fire({ type: "state_changed", state: "paused", media_id: item.id });
|
|
|
|
expect(get(playbackDuration)).toBe(70);
|
|
});
|
|
});
|