Files
jellytau/src/lib/components/library/EpisodeRow.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

224 lines
7.5 KiB
Svelte

<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";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
episode: MediaItem;
focused?: boolean;
/**
* This is the episode the viewer is up to. Marked and scrolled to when the
* series page opens, so a viewer four seasons deep lands on their place
* instead of the top of season 1. TRACES: UR-062 | DR-102
*/
current?: boolean;
onclick?: () => void;
/** Fired when the watched toggle changes, so the series page can reload. */
onWatchedChanged?: () => void;
}
let { episode, focused = false, current = false, onclick, onWatchedChanged }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
onMount(() => {
if ((focused || current) && 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.durationMs) {
return 0;
}
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
});
const duration = $derived(formatDuration(episode.durationMs));
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)]'
: current
? 'ring-2 ring-yellow-400 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.imageId}
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"
>
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
<!-- 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}
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- 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>