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.
224 lines
7.5 KiB
Svelte
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>
|