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
@@ -1,5 +1,6 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation";
@@ -152,7 +153,7 @@
class="flex-1 min-w-0 text-left"
>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.album_name}
{truncateMiddle(album.album_name, 40)}
</p>
<p class="text-xs text-gray-400 truncate">
{album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"}
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
@@ -171,7 +172,7 @@
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.name}
{truncateMiddle(album.name, 40)}
</p>
{#if album.productionYear}
<p class="text-xs text-gray-400">{album.productionYear}</p>
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -159,7 +160,7 @@
<!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
{episode.name}
{truncateMiddle(episode.name, 64)}
</h1>
<!-- Metadata -->
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
@@ -134,7 +135,7 @@
{episodeNumber}.
</span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{episode.name}
{truncateMiddle(episode.name, 56)}
</h3>
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
@@ -229,7 +230,7 @@
/>
</div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 40)}
</p>
{#if item.productionYear}
<p class="text-sm text-gray-400">
@@ -1,5 +1,6 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -106,7 +107,7 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 56)}
</p>
{#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -166,7 +167,7 @@
<div class="mt-2 space-y-0.5">
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 40)}
</p>
{#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
+3 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue";
@@ -242,7 +243,7 @@
<span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
>
{track.name}
{truncateMiddle(track.name, 48)}
</span>
</div>
@@ -356,7 +357,7 @@
{#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span>
{/if}
{track.name}
{truncateMiddle(track.name, 48)}
</p>
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
{#if showArtist && showAlbum}
+2 -1
View File
@@ -1,6 +1,7 @@
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -242,7 +243,7 @@
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1>
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -322,7 +323,7 @@
<div
class="text-sm font-medium text-white truncate block w-full text-left"
>
{displayMedia?.name}
{truncateMiddle(displayMedia?.name, 40)}
</div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length}
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -198,7 +199,7 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
{item.name}
{truncateMiddle(item.name, 48)}
</p>
{#if item.artists?.length}
<p class="text-xs text-gray-400 truncate">
@@ -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);
});
});
+30 -4
View File
@@ -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);
+25 -2
View File
@@ -250,6 +250,29 @@ export const mergedVolume = derived(
}
);
/**
* Whether an item is video content and therefore must NOT appear in the audio
* mini player. Checks the Jellyfin item type (Movie/Episode/live TV) AND the
* backend queue item's `mediaType` discriminator.
*
* The `mediaType` check is essential: a video started via `player_play_item`
* (every Movie/Episode goes through VideoPlayer) is pushed onto the backend
* queue as a PlayerMediaItem with NO `type` field but `mediaType: "video"`.
* Without this, that queue item's absent `type` slips past the Movie/Episode
* check and the video surfaces in the audio mini player after leaving /player.
*/
function isVideoItem(item: MediaItem | null): boolean {
if (!item) return false;
const type = item.type;
if (type === "Movie" || type === "Episode" || type === "TvChannel") {
return true;
}
// Backend PlayerMediaItem carries a lowercase mediaType discriminator that is
// present even when the Jellyfin `type` is absent (video-only play requests).
const mediaType = (item as { mediaType?: string }).mediaType;
return mediaType === "video";
}
/**
* Should show audio miniplayer - state machine gated
* Only true when:
@@ -268,8 +291,8 @@ export const shouldShowAudioMiniPlayer = derived(
// Determine media type from the player state, falling back to the queue
// item (the player store can momentarily lack media during transitions).
const mediaType = $media?.type ?? $queueItem?.type;
if (mediaType === "Movie" || mediaType === "Episode") {
const item = $media ?? $queueItem;
if (isVideoItem(item)) {
return false;
}
+19
View File
@@ -76,6 +76,25 @@ describe("shouldShowAudioMiniPlayer", () => {
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides a video queue item that has no Jellyfin `type` but mediaType 'video'", () => {
// Regression: player_play_item (every Movie/Episode via VideoPlayer) pushes
// a backend PlayerMediaItem onto the queue with item_type: None but
// media_type: Video → serialized with no `type` and mediaType: "video".
// Without the mediaType check this slipped past the Movie/Episode guard and
// surfaced the last-played video in the audio mini player after leaving
// /player.
const videoQueueItem = { id: "v2", mediaType: "video" } as unknown as MediaItem;
currentQueueItem.set(videoQueueItem);
player.setIdle();
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("hides for a live TV channel", () => {
const channelItem = { id: "c1", type: "TvChannel" } as unknown as MediaItem;
player.setPlaying(channelItem, 0, 0);
expect(get(shouldShowAudioMiniPlayer)).toBe(false);
});
it("shows in remote mode when the session has a now-playing item", () => {
isRemoteMode.set(true);
selectedSession.set({ nowPlayingItem: { id: "r1" } });
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { truncateMiddle } from "./truncateMiddle";
describe("truncateMiddle", () => {
it("returns short strings unchanged", () => {
expect(truncateMiddle("short", 32)).toBe("short");
expect(truncateMiddle("exactly-len", 11)).toBe("exactly-len");
});
it("abbreviates in the middle keeping head and tail", () => {
const result = truncateMiddle("long_media_name_like_this", 16);
expect(result).toContain("…");
expect(result.length).toBe(16);
expect(result.startsWith("long")).toBe(true);
expect(result.endsWith("this")).toBe(true);
});
it("never exceeds maxLength", () => {
for (const len of [1, 2, 3, 5, 10, 25]) {
expect(truncateMiddle("a".repeat(100), len).length).toBeLessThanOrEqual(len);
}
});
it("handles null and undefined", () => {
expect(truncateMiddle(null)).toBe("");
expect(truncateMiddle(undefined)).toBe("");
});
it("falls back to head-truncation when there is no room for content", () => {
expect(truncateMiddle("abcdef", 1)).toBe("a");
expect(truncateMiddle("abcdef", 0)).toBe("");
});
it("supports a custom ellipsis", () => {
const result = truncateMiddle("long_media_name_like_this", 16, "...");
expect(result).toContain("...");
expect(result.length).toBe(16);
});
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Abbreviate a long string in the middle so both the start and the end stay
* visible, e.g. `long_media_name_like_this` -> `long_med…ike_this`.
*
* Unlike CSS `text-overflow: ellipsis` (which only clips the end), this keeps
* the tail of a media name often the most distinguishing part (episode
* number, disambiguating suffix, etc.).
*
* @param value The string to abbreviate.
* @param maxLength Maximum length of the returned string, including the ellipsis.
* @param ellipsis The separator inserted in the middle (default ``).
*/
export function truncateMiddle(
value: string | null | undefined,
maxLength = 32,
ellipsis = "…",
): string {
if (value == null) return "";
if (maxLength <= 0) return "";
if (value.length <= maxLength) return value;
// Not enough room for any real content around the ellipsis: fall back to a
// plain head-truncation so we never return more than maxLength characters.
if (maxLength <= ellipsis.length) {
return value.slice(0, maxLength);
}
const keep = maxLength - ellipsis.length;
const head = Math.ceil(keep / 2);
const tail = Math.floor(keep / 2);
return value.slice(0, head) + ellipsis + (tail > 0 ? value.slice(value.length - tail) : "");
}