Files
jellytau/src/lib/components/library/LibraryListView.svelte
T
dtourolleandClaude Opus 4.8 342f95cac1
🏗️ 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
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>
2026-07-01 21:52:27 +02:00

154 lines
5.8 KiB
Svelte

<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";
interface Props {
items: (MediaItem | Library)[];
showProgress?: boolean;
showDownloadStatus?: boolean;
onItemClick?: (item: MediaItem | Library) => void;
}
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
function getDownloadInfo(itemId: string) {
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
}
function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
}
function getSubtitle(item: MediaItem | Library): string {
if (!("type" in item)) return "";
switch (item.type) {
case "Audio":
return item.artists?.join(", ") || item.albumName || "";
case "MusicAlbum":
return item.artistItems?.map((a) => a.name).join(", ") || "";
case "Episode":
return item.seriesName ? `${item.seriesName} - S${item.parentIndexNumber}E${item.indexNumber}` : "";
case "Movie":
case "Series":
return item.productionYear?.toString() || "";
default:
return "";
}
}
function getProgress(item: MediaItem | Library): number {
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
return 0;
}
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
}
function getTrackNumber(item: MediaItem | Library): string {
if ("indexNumber" in item && item.indexNumber) {
return item.indexNumber.toString();
}
return "";
}
</script>
<div class="space-y-1">
{#each items as item, index (item.id)}
{@const subtitle = getSubtitle(item)}
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
<button
type="button"
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
>
<!-- Track number or index -->
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
{trackNum || index + 1}
</span>
<!-- Thumbnail -->
<div class="w-10 h-10 rounded bg-[var(--color-surface)] flex-shrink-0 overflow-hidden relative">
<CachedImage
itemId={item.id}
imageType="Primary"
tag={getImageTag(item)}
maxWidth={80}
alt={item.name}
class="w-full h-full object-cover"
/>
<!-- Play overlay on hover -->
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
<!-- Progress bar -->
{#if progress > 0}
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-700">
<div class="h-full bg-[var(--color-jellyfin)]" style="width: {progress}%"></div>
</div>
{/if}
</div>
<!-- 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">
{truncateMiddle(item.name, 56)}
</p>
{#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
{/if}
</div>
<!-- Download indicator -->
{#if showDownloadStatus && (isDownloaded || isDownloading)}
{#if isDownloaded}
<span title="Downloaded">
<svg class="w-4 h-4 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-label="Downloaded">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</span>
{:else if isDownloading}
<div class="w-4 h-4 relative flex-shrink-0" title="Downloading...">
<svg class="w-4 h-4 -rotate-90" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" opacity="0.3" class="text-blue-500" />
<circle
cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - (downloadInfo?.progress || 0))}
stroke-linecap="round" class="text-blue-500 transition-all duration-300"
/>
</svg>
</div>
{/if}
{/if}
<!-- Played indicator -->
{#if isPlayed}
<svg class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
<!-- Duration -->
{#if duration}
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
{/if}
</button>
{/each}
</div>