351 lines
12 KiB
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>
|