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.
243 lines
8.2 KiB
Svelte
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>
|