Wire up playback reporting, fix duration flash, hide video from audio mini player
Playback reporting (position sync / resume-on-another-device): - player_configure_jellyfin now builds a PlaybackReporter sharing the player controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every auth path (login/restore/reauth); previously they never did. - The PlaybackReporterWrapper now shares the same Arc the controller and MPV progress loop report through, instead of a dead parallel Option. - Android position callbacks now emit throttled progress reports (30s/item), mirroring the MPV backend. Duration flash on pause: - resolveDuration() prefers the live store duration for the already-loaded track over the runTimeTicks estimate, so pausing no longer clobbers the slider's max to 0 when runTimeTicks is missing. Video leaking into audio mini player: - isVideoItem() also checks the backend PlayerMediaItem mediaType discriminator, so a video started via player_play_item (no Jellyfin `type`, mediaType "video") no longer surfaces in the audio mini player. Middle-truncation of long media names: - New truncateMiddle util applied to track/episode/card/mini-player titles so distinguishing tails (episode numbers, suffixes) stay visible. Adds regression tests for the duration and mini-player fixes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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",
|
||||
type: "Audio",
|
||||
runTimeTicks: 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({ runTimeTicks: 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 () => {
|
||||
// 70s in ticks (1 tick = 100ns) → 700_000_000.
|
||||
const item = makeItem({ runTimeTicks: 700_000_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);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
|
||||
import { player, playbackPosition, currentMedia } from "$lib/stores/player";
|
||||
import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
|
||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||
import { playbackMode } from "$lib/stores/playbackMode";
|
||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||
@@ -142,6 +142,26 @@ function handlePositionUpdate(position: number, duration: number): void {
|
||||
// Note: Sleep timer logic is now handled entirely in the Rust backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the duration to seed playing/paused state with.
|
||||
*
|
||||
* For the same track that is already loaded, the live store duration (kept
|
||||
* fresh by media_loaded / position_update events) is authoritative and is
|
||||
* preferred — falling back to the runTimeTicks estimate only when the store
|
||||
* has no usable duration yet. This avoids clobbering a known-good duration
|
||||
* with 0 when runTimeTicks is missing (which would zero the slider's max).
|
||||
*/
|
||||
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
|
||||
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
if (isSameTrack) {
|
||||
const live = get(playbackDuration);
|
||||
if (live > 0) {
|
||||
return live;
|
||||
}
|
||||
}
|
||||
return estimate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle state change events.
|
||||
*
|
||||
@@ -171,7 +191,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
const previous = get(currentMedia);
|
||||
const isSameTrack = previous?.id === currentItem.id;
|
||||
const startPosition = isSameTrack ? get(playbackPosition) : 0;
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||
player.setPlaying(currentItem, startPosition, initialDuration);
|
||||
|
||||
// Trigger preloading of upcoming tracks in the background
|
||||
@@ -180,9 +200,15 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
||||
});
|
||||
} else if (state === "paused" && currentItem) {
|
||||
// Keep current position from store
|
||||
// Keep current position and duration from store. The same track is
|
||||
// already loaded on pause, so its live duration (from media_loaded /
|
||||
// position_update) is authoritative — recomputing from runTimeTicks
|
||||
// would clobber it with 0 when runTimeTicks is missing, forcing the
|
||||
// slider's max to 0 and flashing the thumb to the start.
|
||||
const previous = get(currentMedia);
|
||||
const isSameTrack = previous?.id === currentItem.id;
|
||||
const currentPosition = get(playbackPosition);
|
||||
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
|
||||
const initialDuration = resolveDuration(currentItem, isSameTrack);
|
||||
player.setPaused(currentItem, currentPosition, initialDuration);
|
||||
} else if (state === "loading" && currentItem) {
|
||||
player.setLoading(currentItem);
|
||||
|
||||
Reference in New Issue
Block a user