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.
283 lines
8.9 KiB
Svelte
283 lines
8.9 KiB
Svelte
<!-- TRACES: UR-034 | DR-038 -->
|
|
<script lang="ts">
|
|
import { goto } from "$app/navigation";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
import { createRotationTimer, type RotationTimer } from "./heroRotation";
|
|
|
|
interface Props {
|
|
items: MediaItem[];
|
|
autoRotate?: boolean;
|
|
interval?: number;
|
|
}
|
|
|
|
let { items, autoRotate = true, interval = 6000 }: Props = $props();
|
|
|
|
let currentIndex = $state(0);
|
|
let rotation: RotationTimer | null = null;
|
|
|
|
// Touch/swipe state
|
|
let touchStartX = $state(0);
|
|
let touchEndX = $state(0);
|
|
let isSwiping = $state(false);
|
|
|
|
const currentItem = $derived(items[currentIndex] ?? null);
|
|
|
|
// Compute the best image source for the hero banner (no fetch, pure derivation)
|
|
const heroImageSource = $derived.by(() => {
|
|
if (!currentItem) return null;
|
|
|
|
// 1. Try backdrop image first (best for hero display)
|
|
if (currentItem.backdropImageTags?.[0]) {
|
|
return {
|
|
itemId: currentItem.id,
|
|
imageType: "Backdrop" as const,
|
|
tag: currentItem.backdropImageTags[0],
|
|
};
|
|
}
|
|
|
|
// 2. For episodes, try series/season backdrops
|
|
if (currentItem.kind === "episode") {
|
|
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
|
return {
|
|
itemId: currentItem.seriesId,
|
|
imageType: "Backdrop" as const,
|
|
tag: currentItem.parentBackdropImageTags[0],
|
|
};
|
|
}
|
|
if (currentItem.seriesId) {
|
|
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: undefined };
|
|
}
|
|
if (currentItem.seasonId) {
|
|
return { itemId: currentItem.seasonId, imageType: "Backdrop" as const, tag: undefined };
|
|
}
|
|
}
|
|
|
|
// 3. For music tracks, try album backdrop
|
|
if (currentItem.kind === "track" && currentItem.albumId) {
|
|
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
|
}
|
|
|
|
// 4. Fall back to primary image
|
|
if (currentItem.imageId) {
|
|
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
|
|
}
|
|
|
|
// 5. Last resort for audio: album primary
|
|
if (currentItem.kind === "track" && currentItem.albumId) {
|
|
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
function next() {
|
|
currentIndex = (currentIndex + 1) % items.length;
|
|
}
|
|
|
|
function prev() {
|
|
currentIndex = (currentIndex - 1 + items.length) % items.length;
|
|
}
|
|
|
|
// Manual navigation (swipe, arrows, dots) restarts the countdown, so the
|
|
// banner always waits a full interval after the last change instead of
|
|
// firing whatever was left of the previous one.
|
|
function showNext() {
|
|
next();
|
|
rotation?.restart();
|
|
}
|
|
|
|
function showPrev() {
|
|
prev();
|
|
rotation?.restart();
|
|
}
|
|
|
|
function goToIndex(idx: number) {
|
|
currentIndex = idx;
|
|
rotation?.restart();
|
|
}
|
|
|
|
// Touch/swipe handlers
|
|
function handleTouchStart(e: TouchEvent) {
|
|
touchStartX = e.touches[0].clientX;
|
|
touchEndX = e.touches[0].clientX;
|
|
isSwiping = true;
|
|
}
|
|
|
|
function handleTouchMove(e: TouchEvent) {
|
|
if (!isSwiping) return;
|
|
touchEndX = e.touches[0].clientX;
|
|
}
|
|
|
|
function handleTouchEnd() {
|
|
if (!isSwiping) return;
|
|
isSwiping = false;
|
|
|
|
const swipeThreshold = 50; // Minimum swipe distance in pixels
|
|
const diff = touchStartX - touchEndX;
|
|
|
|
if (Math.abs(diff) > swipeThreshold) {
|
|
if (diff > 0) {
|
|
// Swiped left - go to next
|
|
showNext();
|
|
} else {
|
|
// Swiped right - go to previous
|
|
showPrev();
|
|
}
|
|
}
|
|
|
|
touchStartX = 0;
|
|
touchEndX = 0;
|
|
}
|
|
|
|
// Auto-rotate logic
|
|
$effect(() => {
|
|
if (autoRotate && items.length > 1) {
|
|
const timer = createRotationTimer(interval, next);
|
|
rotation = timer;
|
|
timer.restart();
|
|
return () => {
|
|
timer.stop();
|
|
rotation = null;
|
|
};
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
role="region"
|
|
class="relative h-[500px] rounded-xl overflow-hidden group mb-8 touch-pan-y"
|
|
ontouchstart={handleTouchStart}
|
|
ontouchmove={handleTouchMove}
|
|
ontouchend={handleTouchEnd}
|
|
>
|
|
{#if heroImageSource}
|
|
<CachedImage
|
|
itemId={heroImageSource.itemId}
|
|
imageType={heroImageSource.imageType}
|
|
tag={heroImageSource.tag}
|
|
maxWidth={1920}
|
|
alt={currentItem?.name ?? ""}
|
|
class="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
{:else}
|
|
<div
|
|
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- Gradient overlay -->
|
|
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
|
|
|
|
{#if currentItem}
|
|
<!-- Content -->
|
|
<div class="relative h-full flex flex-col justify-end p-12 max-w-3xl">
|
|
<div class="space-y-4">
|
|
<h1 class="text-5xl font-bold text-white drop-shadow-lg">
|
|
{currentItem.name}
|
|
</h1>
|
|
|
|
<!-- Metadata -->
|
|
<div class="flex items-center gap-4 text-sm text-gray-200">
|
|
{#if currentItem.productionYear}
|
|
<span>{currentItem.productionYear}</span>
|
|
{/if}
|
|
{#if currentItem.communityRating}
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
|
|
/>
|
|
</svg>
|
|
{currentItem.communityRating.toFixed(1)}
|
|
</span>
|
|
{/if}
|
|
{#if currentItem.officialRating}
|
|
<span class="px-2 py-0.5 border border-gray-300 rounded text-xs">
|
|
{currentItem.officialRating}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if currentItem.overview}
|
|
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed">
|
|
{currentItem.overview}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3 pt-2">
|
|
<button
|
|
onclick={() => goto(`/player/${currentItem.id}`)}
|
|
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
Play
|
|
</button>
|
|
<button
|
|
onclick={() => {
|
|
// Navigate to full series detail page with cast/crew/related content
|
|
// (even for episodes, show the series page so users see cast and related items)
|
|
if (currentItem.kind === "episode" && currentItem.seriesId) {
|
|
goto(`/library/${currentItem.seriesId}`);
|
|
} else {
|
|
goto(`/library/${currentItem.id}`);
|
|
}
|
|
}}
|
|
class="px-8 py-3 bg-gray-600/80 hover:bg-gray-600 backdrop-blur-sm rounded-lg font-semibold text-lg transition-colors"
|
|
>
|
|
More Info
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
{#if items.length > 1}
|
|
<!-- Indicators / Location Bar -->
|
|
<div
|
|
class="absolute bottom-6 left-1/2 transform -translate-x-1/2 flex gap-3 bg-black/40 backdrop-blur-sm px-4 py-2 rounded-full"
|
|
>
|
|
{#each items as _, idx}
|
|
<button
|
|
onclick={() => goToIndex(idx)}
|
|
class="h-2 rounded-full transition-all hover:bg-white/80 cursor-pointer {idx ===
|
|
currentIndex
|
|
? 'bg-white w-12'
|
|
: 'bg-white/50 w-8'}"
|
|
aria-label={`Go to item ${idx + 1}: ${items[idx]?.name || ""}`}
|
|
></button>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Swipe Indicators (Desktop hover) -->
|
|
<button
|
|
onclick={showPrev}
|
|
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
|
aria-label="Previous item"
|
|
>
|
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15 19l-7-7 7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<button
|
|
onclick={showNext}
|
|
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
|
|
aria-label="Next item"
|
|
>
|
|
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|