jellytau/src/lib/components/library/EpisodeFocusView.svelte

351 lines
12 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
interface Props {
episode: MediaItem;
series: MediaItem;
allEpisodes: MediaItem[];
onBack?: () => void;
}
let { episode, series, allEpisodes, onBack }: Props = $props();
// Check if an episode matches the focused episode (by ID or season/episode number)
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
// Also match by season/episode number in case IDs differ
return ep.parentIndexNumber === episode.parentIndexNumber &&
ep.indexNumber === episode.indexNumber;
}
// Find adjacent episodes - use season/episode numbers if ID not found
const adjacentEpisodes = $derived(() => {
// First, try to find the episode by ID
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
// If not found by ID, try to find by season/episode number
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
idx = allEpisodes.findIndex(
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
);
}
// If still not found, filter to same season and show those centered around the episode number
if (idx === -1) {
const sameSeasonEpisodes = allEpisodes
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
if (sameSeasonEpisodes.length > 0) {
// Find position based on episode number
const epNum = episode.indexNumber || 1;
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
const start = Math.max(0, actualIdx - 3);
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
const result = sameSeasonEpisodes.slice(start, end);
// Insert the focused episode if not already present (by season/episode number match)
const hasCurrentEpisode = result.some(isCurrentEpisode);
if (!hasCurrentEpisode) {
// Insert at correct position based on episode number
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
if (insertIdx === -1) {
result.push(episode);
} else {
result.splice(insertIdx, 0, episode);
}
}
return result;
}
// Last resort: return focused episode with first 9 episodes
return [episode, ...allEpisodes.slice(0, 9)];
}
// Get 3 before and 6 after (or adjust based on position)
const start = Math.max(0, idx - 3);
const end = Math.min(allEpisodes.length, idx + 7);
return allEpisodes.slice(start, end);
});
function getBackdropUrl(): string {
try {
const repo = auth.getRepository();
// Try episode backdrop first
if (episode.backdropImageTags?.[0]) {
return repo.getImageUrl(episode.id, "Backdrop", {
maxWidth: 1920,
tag: episode.backdropImageTags[0],
});
}
// Try episode primary (thumbnail)
if (episode.primaryImageTag) {
return repo.getImageUrl(episode.id, "Primary", {
maxWidth: 1920,
tag: episode.primaryImageTag,
});
}
// Fall back to series backdrop
if (series.backdropImageTags?.[0]) {
return repo.getImageUrl(series.id, "Backdrop", {
maxWidth: 1920,
tag: series.backdropImageTags[0],
});
}
return "";
} catch {
return "";
}
}
function getEpisodeThumbnail(ep: MediaItem): string {
try {
const repo = auth.getRepository();
return repo.getImageUrl(ep.id, "Primary", {
maxWidth: 400,
tag: ep.primaryImageTag,
});
} catch {
return "";
}
}
function formatDuration(ticks?: number): string {
if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function getProgress(ep: MediaItem): number {
if (!ep.userData || !ep.runTimeTicks) {
return 0;
}
return (ep.userData.playbackPositionTicks / ep.runTimeTicks) * 100;
}
function handlePlay() {
goto(`/player/${episode.id}`);
}
function handleEpisodeClick(ep: MediaItem) {
goto(`/library/${series.id}?episode=${ep.id}`);
}
const backdropUrl = $derived(getBackdropUrl());
const episodeLabel = $derived(
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
);
const duration = $derived(formatDuration(episode.runTimeTicks));
const progress = $derived(getProgress(episode));
</script>
<div class="space-y-8">
<!-- Hero section -->
<div class="relative h-[450px] rounded-xl overflow-hidden">
{#if backdropUrl}
<img
src={backdropUrl}
alt={episode.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>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<!-- Back button -->
{#if onBack}
<button
onclick={onBack}
class="absolute top-4 left-4 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
title="Back to series"
>
<svg class="w-6 h-6" 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>
{/if}
<!-- Content -->
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
<div class="space-y-4">
<!-- Series name -->
<p class="text-gray-300 text-lg">{series.name}</p>
<!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
{episode.name}
</h1>
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-200">
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
{episodeLabel}
</span>
{#if duration}
<span>{duration}</span>
{/if}
{#if episode.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>
{episode.communityRating.toFixed(1)}
</span>
{/if}
{#if episode.userData?.played}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
Watched
</span>
{/if}
</div>
<!-- Overview -->
{#if episode.overview}
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed max-w-2xl">
{episode.overview}
</p>
{/if}
<!-- Progress bar if in progress -->
{#if progress > 0 && progress < 95}
<div class="w-64">
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {progress}%"
></div>
</div>
<p class="text-xs text-gray-400 mt-1">
{Math.round(progress)}% watched
</p>
</div>
{/if}
<!-- Play button -->
<div class="pt-2">
<button
onclick={handlePlay}
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>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
</div>
</div>
</div>
</div>
<!-- Adjacent episodes -->
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
{#each adjacentEpisodes() as ep (ep.id)}
{@const isCurrent = isCurrentEpisode(ep)}
{@const epProgress = getProgress(ep)}
{@const thumbUrl = getEpisodeThumbnail(ep)}
<button
onclick={() => !isCurrent && handleEpisodeClick(ep)}
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
disabled={isCurrent}
>
<!-- Thumbnail -->
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
{#if thumbUrl}
<img
src={thumbUrl}
alt={ep.name}
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-gray-600">
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/>
</svg>
</div>
{/if}
<!-- Hover overlay -->
{#if !isCurrent}
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
{/if}
<!-- Now Playing indicator -->
{#if isCurrent}
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
Current
</div>
{/if}
<!-- Progress bar -->
{#if epProgress > 0}
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
<div
class="h-full bg-[var(--color-jellyfin)]"
style="width: {epProgress}%"
></div>
</div>
{/if}
<!-- Played indicator -->
{#if ep.userData?.played}
<div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
{/if}
</div>
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
{ep.indexNumber || 0}.
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
</p>
</div>
{#if ep.overview}
<p class="text-gray-400 text-sm line-clamp-2">
{ep.overview}
</p>
{/if}
</div>
</button>
{/each}
</div>
</div>
</div>