First working POC

This commit is contained in:
2026-01-26 22:21:54 +01:00
commit cfddc1edea
255 changed files with 77606 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
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);
function getHeroImageUrl(): string {
if (!currentItem) return "";
const repo = auth.getRepository();
// 1. Try backdrop image first (best for hero display)
if (currentItem.backdropImageTags?.[0]) {
return repo.getImageUrl(currentItem.id, "Backdrop", {
maxWidth: 1920,
tag: currentItem.backdropImageTags[0],
});
}
// 2. For episodes, try to use series backdrop from parent
if (currentItem.type === "Episode") {
// First try parent backdrop tags (includes image tag for caching)
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
tag: currentItem.parentBackdropImageTags[0],
});
}
// Fallback: try series backdrop without tag (may not be cached optimally)
if (currentItem.seriesId) {
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
maxWidth: 1920,
});
}
// Last resort for episodes: try season backdrop
if (currentItem.seasonId) {
return repo.getImageUrl(currentItem.seasonId, "Backdrop", {
maxWidth: 1920,
});
}
}
// 3. For music tracks, try album backdrop first, then primary
if (currentItem.type === "Audio" && currentItem.albumId) {
// Try album backdrop first (more cinematic for hero)
return repo.getImageUrl(currentItem.albumId, "Backdrop", {
maxWidth: 1920,
});
}
// 4. Fall back to primary image (poster, album art, episode thumbnail)
if (currentItem.primaryImageTag) {
return repo.getImageUrl(currentItem.id, "Primary", {
maxWidth: 1920,
tag: currentItem.primaryImageTag,
});
}
// 5. Last resort for audio: try album primary image
if (currentItem.type === "Audio" && currentItem.albumId) {
return repo.getImageUrl(currentItem.albumId, "Primary", {
maxWidth: 1920,
});
}
return "";
}
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;
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);
};
}
});
const heroImageUrl = $derived(getHeroImageUrl());
</script>
<div
class="relative h-[500px] rounded-xl overflow-hidden group mb-8 touch-pan-y"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
>
{#if heroImageUrl}
<img
src={heroImageUrl}
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.type === "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>