Files
jellytau/src/lib/components/home/HeroBanner.svelte
T
dtourolle 772e9ca6d5 domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)
Migrate catalog MediaItem consumers from stringly item.type ("Audio",
"MusicAlbum", …) to the neutral item.kind enum across all classification
logic: home, library detail, player routing, artist/person/related/genre
components, tv store.

Model refinements found during migration (each a real distinction the
flat item_type collapsed):
- MediaKind::LiveChannel — live TV (playable, non-seekable) vs
- MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs
- MediaKind::Channel — channel container (drill-in).
  TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem.

RelatedItemsSection and GenreTags props migrated from Jellyfin type
strings to MediaKind; MediaKind re-exported from api/types.

Deferred by design: display {item.type} text, ResultsCounter labels,
Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math
(coupled to playbackPositionTicks — phase 3). Old fields still dual-carried
so nothing breaks.

Rust 456 + 7 domain tests, frontend 644 tests, check clean.
2026-07-23 21:12:20 +02:00

243 lines
8.2 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";
interface Props {
items: MediaItem[];
autoRotate?: boolean;
interval?: number;
}
let { items, autoRotate = true, interval = 6000 }: Props = $props();
let currentIndex = $state(0);
let intervalId: number | 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;
}
function goToIndex(idx: number) {
currentIndex = idx;
}
// 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
next();
} else {
// Swiped right - go to previous
prev();
}
}
touchStartX = 0;
touchEndX = 0;
}
// Auto-rotate logic
$effect(() => {
if (autoRotate && items.length > 1) {
intervalId = window.setInterval(next, interval);
return () => {
if (intervalId) clearInterval(intervalId);
};
}
});
</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={prev}
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={next}
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>