detail page components
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { useNavigate, type NavigateFunction } from 'react-router-dom'
|
||||
import {
|
||||
Play,
|
||||
Heart,
|
||||
Clock,
|
||||
Star,
|
||||
Plus,
|
||||
Share2,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Tv2,
|
||||
Film as FilmIcon,
|
||||
Disc3,
|
||||
} from '../../lib/icons'
|
||||
import type { BaseItemDto } from '../../api/types'
|
||||
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
|
||||
import type { CinemetaMeta } from '../../api/cinemeta'
|
||||
import MetaBadges from '../ui/MetaBadges'
|
||||
import WatchlistButton from '../ui/WatchlistButton'
|
||||
import RequestButton from '../request/RequestButton'
|
||||
import ExternalLinks from './ExternalLinks'
|
||||
import HeroTechCard from './HeroTechCard'
|
||||
import CastMenu from './CastMenu'
|
||||
import { useYoutubeViewer } from '../../stores/youtube-viewer-store'
|
||||
import { usePreferencesStore } from '../../stores/preferences-store'
|
||||
import type { useRefreshItem, useSampleEpisode } from '../../hooks/use-jellyfin'
|
||||
import { buildShareUrl, copyToClipboard } from '../../lib/share'
|
||||
import { toast } from '../../stores/toast-store'
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto
|
||||
itemType: string
|
||||
tmdbMovieData: TmdbMovie | null | undefined
|
||||
tmdbTvData: TmdbTvShow | null | undefined
|
||||
tmdbId: string | null | undefined
|
||||
posterUrl: string
|
||||
logoUrl: string | null
|
||||
backdropUrl: string | null
|
||||
title: string
|
||||
year?: number | null
|
||||
rating: string | null | undefined
|
||||
runtime: string | null
|
||||
taglineText: string | null | undefined
|
||||
genres: string[]
|
||||
resumeTime: string | null
|
||||
progress: number | null | undefined
|
||||
isFavorite: boolean | null | undefined
|
||||
playUrl: string
|
||||
resumeUrl: string
|
||||
trailer: { key: string; official?: boolean } | null | undefined
|
||||
cinemetaData: CinemetaMeta | null | undefined
|
||||
showTmdbRatings: boolean
|
||||
sampleEpisode: ReturnType<typeof useSampleEpisode>
|
||||
refresh: ReturnType<typeof useRefreshItem>
|
||||
}
|
||||
|
||||
export default function DetailHero({
|
||||
item,
|
||||
itemType,
|
||||
tmdbMovieData,
|
||||
tmdbTvData,
|
||||
tmdbId,
|
||||
posterUrl,
|
||||
logoUrl,
|
||||
backdropUrl,
|
||||
title,
|
||||
year,
|
||||
rating,
|
||||
runtime,
|
||||
taglineText,
|
||||
genres,
|
||||
resumeTime,
|
||||
progress,
|
||||
isFavorite,
|
||||
playUrl,
|
||||
resumeUrl,
|
||||
trailer,
|
||||
cinemetaData,
|
||||
showTmdbRatings,
|
||||
sampleEpisode,
|
||||
refresh,
|
||||
}: Props) {
|
||||
const navigate = useNavigate()
|
||||
const tmdbData = itemType === 'Movie' ? tmdbMovieData : tmdbTvData
|
||||
const preRollTrailers = usePreferencesStore(s => s.preRollTrailers)
|
||||
const youtube = useYoutubeViewer()
|
||||
|
||||
function handlePlay() {
|
||||
if (preRollTrailers && trailer && itemType === 'Movie') {
|
||||
youtube.show({
|
||||
videoKey: trailer.key,
|
||||
title: `${title} - Trailer`,
|
||||
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
|
||||
onClose: () => navigate(playUrl),
|
||||
})
|
||||
return
|
||||
}
|
||||
navigate(playUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-[68vh] min-h-[520px] -mt-14 overflow-hidden">
|
||||
{backdropUrl && (
|
||||
<motion.div
|
||||
initial={{ scale: 1.06, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute inset-0 bg-cover bg-top"
|
||||
style={{ backgroundImage: `url(${backdropUrl})` }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-void/10" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_75%_25%,transparent_0%,rgba(0,0,0,0.5)_85%)]" />
|
||||
|
||||
<div className="relative h-full flex items-end pb-12 px-7 gap-7">
|
||||
{/* Left column: type pill + poster */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||
className="hidden md:flex flex-col gap-3 shrink-0"
|
||||
>
|
||||
{itemType && <TypePill itemType={itemType} />}
|
||||
{posterUrl && (
|
||||
<motion.img
|
||||
src={posterUrl}
|
||||
alt={title}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1], delay: 0.15 }}
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
className="w-[180px] aspect-[2/3] object-cover rounded-xl ring-1 ring-white/10 shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)]"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
|
||||
className="flex-1 min-w-0 max-w-3xl"
|
||||
>
|
||||
{/* Type pill (small screens only - shown above poster on md+) */}
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap md:hidden">
|
||||
{itemType && <TypePill itemType={itemType} />}
|
||||
<MetaBadges item={item} variant="hero" />
|
||||
</div>
|
||||
|
||||
{itemType === 'Episode' && item.SeriesId && (
|
||||
<EpisodeBreadcrumb item={item} navigate={navigate} />
|
||||
)}
|
||||
|
||||
{logoUrl ? (
|
||||
<div className="mb-4 flex justify-start">
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={title}
|
||||
className="block h-auto max-h-40 md:max-h-48 max-w-[520px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="font-display text-5xl md:text-6xl font-bold text-white leading-[0.95] tracking-tight mb-4 text-left drop-shadow-[0_4px_16px_rgba(0,0,0,0.55)]">
|
||||
{title}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{taglineText && (
|
||||
<p className="text-white/65 text-[15px] italic mb-3 max-w-xl font-display">
|
||||
"{taglineText}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2.5 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
|
||||
{year && <span className="tabular-nums">{year}</span>}
|
||||
{year && (rating || runtime) && <Dot />}
|
||||
{rating && (
|
||||
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
|
||||
{rating}
|
||||
</span>
|
||||
)}
|
||||
{rating && runtime && <Dot />}
|
||||
{runtime && (
|
||||
<span className="flex items-center gap-1 tabular-nums">
|
||||
<Clock size={11} /> {runtime}
|
||||
</span>
|
||||
)}
|
||||
{/* Ratings shown on the right-side card (and here only on small screens for accessibility) */}
|
||||
<span className="lg:hidden flex items-center gap-2.5">
|
||||
{showTmdbRatings && tmdbData?.vote_average != null && tmdbData.vote_average > 0 && (
|
||||
<>
|
||||
<Dot />
|
||||
<span className="flex items-center gap-1" title="TMDB community score">
|
||||
<Star size={11} className="text-accent fill-accent" />
|
||||
<span className="tabular-nums">{tmdbData.vote_average.toFixed(1)}</span>
|
||||
<span className="text-white/45 text-[10px] uppercase tracking-wide">TMDB</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.CommunityRating != null && item.CommunityRating > 0 && (
|
||||
<>
|
||||
<Dot />
|
||||
<span className="flex items-center gap-1 text-cool" title="Jellyfin community average">
|
||||
<Star size={11} className="fill-current" />
|
||||
<span className="tabular-nums">{item.CommunityRating.toFixed(1)}</span>
|
||||
<span className="text-white/45 text-[10px] uppercase tracking-wide">Jellyfin</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{cinemetaData?.imdbRating && Number(cinemetaData.imdbRating) > 0 && (
|
||||
<>
|
||||
<Dot />
|
||||
<span className="flex items-center gap-1" title="IMDB rating">
|
||||
<Star size={11} className="text-yellow-400 fill-yellow-400" />
|
||||
<span className="tabular-nums">{Number(cinemetaData.imdbRating).toFixed(1)}</span>
|
||||
<span className="text-white/45 text-[10px] uppercase tracking-wide">IMDB</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
{genres.slice(0, 5).map(g => (
|
||||
<span
|
||||
key={g}
|
||||
className="px-2.5 h-6 inline-flex items-center bg-white/8 hover:bg-white/12 backdrop-blur text-white/80 text-[11px] rounded-full border border-white/8 transition-colors cursor-default"
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap mb-5">
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className="group h-11 px-6 bg-white text-void rounded-lg flex items-center gap-2 text-[14px] font-semibold transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-black/30 focus-ring"
|
||||
>
|
||||
<Play size={16} strokeWidth={0} fill="currentColor" />
|
||||
Play
|
||||
</button>
|
||||
|
||||
{resumeTime && (
|
||||
<button
|
||||
onClick={() => navigate(resumeUrl)}
|
||||
className="h-11 px-4 bg-accent/15 text-accent border border-accent/30 hover:bg-accent/20 rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||
>
|
||||
<Play size={14} strokeWidth={0} fill="currentColor" />
|
||||
Resume from <span className="tabular-nums">{resumeTime}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{trailer && (
|
||||
<button
|
||||
onClick={() =>
|
||||
useYoutubeViewer.getState().show({
|
||||
videoKey: trailer.key,
|
||||
title: `${title} - Trailer`,
|
||||
subtitle: trailer.official ? 'Official trailer' : 'Trailer',
|
||||
})
|
||||
}
|
||||
className="h-11 px-4 bg-white/10 hover:bg-white/15 text-white border border-white/15 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||
>
|
||||
Trailer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{item.Id && (item.Type === 'Movie' || item.Type === 'Series') && (
|
||||
<WatchlistButton itemId={item.Id} />
|
||||
)}
|
||||
|
||||
{tmdbId && (item.Type === 'Movie' || item.Type === 'Series') && (
|
||||
<RequestButton
|
||||
tmdbId={Number(tmdbId)}
|
||||
kind={item.Type === 'Movie' ? 'movie' : 'tv'}
|
||||
tmdbData={tmdbData as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton aria-label={isFavorite ? 'Remove favorite' : 'Add favorite'} active={!!isFavorite}>
|
||||
<Heart size={16} className={isFavorite ? 'text-accent fill-accent' : 'text-white/80'} />
|
||||
</IconButton>
|
||||
<IconButton aria-label="Add to playlist">
|
||||
<Plus size={16} className="text-white/80" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="Copy share link"
|
||||
title="Copy share link"
|
||||
onClick={async () => {
|
||||
const url = buildShareUrl({
|
||||
tmdbId,
|
||||
imdbId: item.ProviderIds?.Imdb,
|
||||
kind: itemType === 'Series' || itemType === 'Episode' ? 'tv' : 'movie',
|
||||
})
|
||||
if (!url) {
|
||||
toast('No shareable link for this item', 'error')
|
||||
return
|
||||
}
|
||||
const ok = await copyToClipboard(url)
|
||||
toast(ok ? 'Link copied to clipboard' : 'Could not copy link', ok ? 'success' : 'error')
|
||||
}}
|
||||
>
|
||||
<Share2 size={16} className="text-white/80" />
|
||||
</IconButton>
|
||||
{item.Id && <CastMenu itemId={item.Id} mediaType={item.MediaType ?? undefined} />}
|
||||
<IconButton
|
||||
aria-label="Refresh metadata"
|
||||
onClick={() => {
|
||||
if (!item.Id) return
|
||||
refresh.mutate({
|
||||
itemId: item.Id,
|
||||
replaceAllMetadata: false,
|
||||
replaceAllImages: false,
|
||||
})
|
||||
}}
|
||||
disabled={refresh.isPending}
|
||||
title={
|
||||
refresh.isPending
|
||||
? 'Refreshing...'
|
||||
: refresh.isSuccess
|
||||
? 'Refresh queued'
|
||||
: 'Re-scrape metadata + images from TMDB / TVDB'
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`text-white/80 ${refresh.isPending ? 'animate-[spin-soft_0.8s_linear_infinite]' : ''}`}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<ExternalLinks
|
||||
tmdbId={tmdbId}
|
||||
type={itemType === 'Series' ? 'tv' : 'movie'}
|
||||
ids={tmdbData?.external_ids}
|
||||
jellyfinExternalUrls={item.ExternalUrls as any}
|
||||
/>
|
||||
|
||||
{progress != null && progress > 0 && (
|
||||
<div className="mt-5 max-w-md">
|
||||
<div className="flex items-center justify-between text-[10px] text-white/60 mb-1.5 font-medium uppercase tracking-wider">
|
||||
<span>Watch progress</span>
|
||||
<span className="tabular-nums">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
|
||||
className="h-full bg-accent shadow-[0_0_8px_rgba(245,182,66,0.6)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.35 }}
|
||||
className="self-end"
|
||||
>
|
||||
<HeroTechCard
|
||||
techItem={itemType === 'Series' ? sampleEpisode.data : item}
|
||||
tmdbVote={tmdbData?.vote_average}
|
||||
tmdbVoteCount={tmdbData?.vote_count}
|
||||
jellyfinCommunity={item.CommunityRating}
|
||||
jellyfinCritic={item.CriticRating}
|
||||
imdbRating={
|
||||
cinemetaData?.imdbRating
|
||||
? Number(cinemetaData.imdbRating)
|
||||
: null
|
||||
}
|
||||
showTmdbRatings={showTmdbRatings}
|
||||
seasonsCount={
|
||||
itemType === 'Series'
|
||||
? tmdbTvData?.number_of_seasons ?? null
|
||||
: null
|
||||
}
|
||||
episodesCount={
|
||||
itemType === 'Series'
|
||||
? tmdbTvData?.number_of_episodes ?? null
|
||||
: null
|
||||
}
|
||||
seriesStatus={itemType === 'Series' ? tmdbTvData?.status ?? null : null}
|
||||
onRescan={
|
||||
item.Id
|
||||
? () =>
|
||||
refresh.mutate({
|
||||
itemId: item.Id!,
|
||||
replaceAllMetadata: false,
|
||||
replaceAllImages: false,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
isRescanning={refresh.isPending}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-b from-transparent to-void pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TypePill({ itemType }: { itemType: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 self-start rounded-full bg-white/8 border border-white/12 backdrop-blur text-[10px] font-semibold uppercase tracking-[0.12em] text-white/80">
|
||||
{itemType === 'Series' || itemType === 'Episode' ? (
|
||||
<Tv2 size={10} className="text-accent" />
|
||||
) : itemType === 'MusicAlbum' || itemType === 'MusicArtist' ? (
|
||||
<Disc3 size={10} className="text-accent" />
|
||||
) : (
|
||||
<FilmIcon size={10} className="text-accent" />
|
||||
)}
|
||||
{itemType === 'Series' ? 'Series' : itemType === 'Movie' ? 'Movie' : itemType}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EpisodeBreadcrumb({ item, navigate }: { item: BaseItemDto; navigate: NavigateFunction }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(`/item/${item.SeriesId}`)}
|
||||
className="inline-flex items-center gap-2 mb-3 group focus-ring rounded"
|
||||
>
|
||||
<span className="text-[11px] font-semibold text-white/70 uppercase tracking-[0.18em] group-hover:text-white transition">
|
||||
{item.SeriesName}
|
||||
</span>
|
||||
{item.ParentIndexNumber != null && item.IndexNumber != null && (
|
||||
<span className="text-[11px] text-white/50 font-medium tabular-nums">
|
||||
S{item.ParentIndexNumber} · E{item.IndexNumber}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight size={12} className="text-white/40 group-hover:text-white/80 group-hover:translate-x-0.5 transition" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot() {
|
||||
return <span className="text-white/30">·</span>
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
active,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`w-11 h-11 rounded-lg backdrop-blur grid place-items-center transition-all duration-200 hover:scale-105 active:scale-95 focus-ring disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 ${
|
||||
active
|
||||
? 'bg-accent/15 border border-accent/30'
|
||||
: 'bg-white/10 border border-white/15 hover:bg-white/15'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user