Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s

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:
2026-07-01 21:52:27 +02:00
co-authored by Claude Opus 4.8
parent dcee342c47
commit 342f95cac1
21 changed files with 420 additions and 28 deletions
@@ -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);
});
});