jellytau/src/lib/components/library/EpisodeRow.svelte
Duncan Tourolle 14e9d7e03a
Some checks failed
Traceability Validation / Check Requirement Traces (push) Failing after 21s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m14s
E frontend reconciliation (2/2): align consumers to generated bindings; svelte-check clean
Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):

- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
  (CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
  send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
  NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
  deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
  sessions.test.ts NowPlayingItem mock.

Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
2026-06-20 20:49:20 +02:00

178 lines
6.1 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
episode: MediaItem;
focused?: boolean;
onclick?: () => void;
}
let { episode, focused = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
onMount(() => {
if (focused && buttonRef) {
// Scroll into view with some offset from top
setTimeout(() => {
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
}
});
// Check if this episode is downloaded
const downloadInfo = $derived(
Object.values($downloads.downloads).find((d) => d.itemId === episode.id)
);
const isDownloaded = $derived(downloadInfo?.status === "completed");
const isDownloading = $derived(
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"
);
const downloadProgress = $derived(downloadInfo?.progress || 0);
const progress = $derived(() => {
if (!episode.userData || !episode.runTimeTicks) {
return 0;
}
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
});
const duration = $derived(formatDuration(episode.runTimeTicks));
const episodeNumber = $derived(episode.indexNumber || 0);
</script>
<button
bind:this={buttonRef}
type="button"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
{onclick}
>
<!-- Thumbnail -->
<div class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
<CachedImage
itemId={episode.id}
imageType="Primary"
tag={episode.primaryImageTag}
maxWidth={320}
alt={episode.name}
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
/>
<!-- Hover overlay with play icon -->
<div class="absolute inset-0 bg-black/0 group-hover/row:bg-black/30 transition-colors flex items-center justify-center">
<div class="opacity-0 group-hover/row:opacity-100 transition-opacity">
<div class="w-10 h-10 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<svg class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
<!-- Progress bar -->
{#if progress() > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress()}%"
></div>
</div>
{/if}
<!-- Download indicator -->
{#if isDownloaded || isDownloading}
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
{#if isDownloaded}
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
<svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
{:else if isDownloading}
<div class="w-5 h-5 relative">
<svg class="w-5 h-5 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="rgba(0,0,0,0.6)"
stroke="rgba(255,255,255,0.3)"
stroke-width="2"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="#3b82f6"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - downloadProgress)}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
</div>
{/if}
</div>
{/if}
</div>
<!-- Episode info -->
<div class="flex-1 min-w-0 py-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<!-- Episode number and title -->
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] font-semibold text-sm">
{episodeNumber}.
</span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{episode.name}
</h3>
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" 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}
</div>
<!-- Overview -->
{#if episode.overview}
<p class="text-gray-400 text-sm mt-1 line-clamp-2">
{episode.overview}
</p>
{/if}
</div>
<!-- Duration and Download -->
<div class="flex items-center gap-2 flex-shrink-0">
{#if duration}
<span class="text-gray-500 text-sm">
{duration}
</span>
{/if}
<!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton
itemId={episode.id}
itemName={episode.name}
seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm"
/>
</div>
</div>
</div>
</div>
</button>