Files
jellytau/src/lib/components/home/HeroBanner.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

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>