main pages
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Boxes, Calendar } from '../lib/icons'
|
||||||
|
import { useTmdbCollection } from '../hooks/use-tmdb'
|
||||||
|
import { getTmdbImageUrl } from '../api/tmdb'
|
||||||
|
import { usePosterGridClasses } from '../lib/density'
|
||||||
|
|
||||||
|
export default function CollectionPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: collection, isLoading } = useTmdbCollection(id)
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
|
||||||
|
if (isLoading) return <CollectionSkeleton />
|
||||||
|
if (!collection) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
|
||||||
|
<p className="text-text-2 text-[15px] mb-1">Collection not found</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by release date ascending; items with no/unparseable date sort
|
||||||
|
// to the end so unannounced sequels don't end up at #1.
|
||||||
|
const parts = (collection.parts || []).slice().sort((a, b) => {
|
||||||
|
const ta = a.release_date ? Date.parse(a.release_date) : NaN
|
||||||
|
const tb = b.release_date ? Date.parse(b.release_date) : NaN
|
||||||
|
const da = Number.isFinite(ta) ? ta : Number.POSITIVE_INFINITY
|
||||||
|
const db = Number.isFinite(tb) ? tb : Number.POSITIVE_INFINITY
|
||||||
|
return da - db
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRuntime = parts.reduce((sum, p) => sum + (p.runtime || 0), 0)
|
||||||
|
const avgRating =
|
||||||
|
parts.length > 0
|
||||||
|
? parts.reduce((sum, p) => sum + (p.vote_average || 0), 0) / parts.length
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="relative h-[50vh] min-h-[380px] -mt-14 overflow-hidden">
|
||||||
|
{collection.backdrop_path && (
|
||||||
|
<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(${getTmdbImageUrl(collection.backdrop_path, 'w1920')})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/65 to-void/15" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative h-full flex items-end pb-12 px-7">
|
||||||
|
<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.15 }}
|
||||||
|
className="max-w-3xl"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent/12 border border-accent/30 text-accent text-[10px] font-semibold uppercase tracking-[0.12em] mb-3">
|
||||||
|
<Boxes size={10} />
|
||||||
|
Collection
|
||||||
|
</span>
|
||||||
|
<h1 className="font-display text-4xl md:text-5xl font-bold text-white tracking-tight leading-[1] mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
|
||||||
|
{collection.name}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3 text-[12px] text-white/70 font-medium mb-3 flex-wrap">
|
||||||
|
<span>{parts.length} {parts.length === 1 ? 'film' : 'films'}</span>
|
||||||
|
{totalRuntime > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/30">·</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{Math.floor(totalRuntime / 60)}h {totalRuntime % 60}m total
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{avgRating > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-white/30">·</span>
|
||||||
|
<span className="tabular-nums">{avgRating.toFixed(1)} avg rating</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collection.overview && (
|
||||||
|
<p className="text-[14px] text-white/80 leading-relaxed line-clamp-3 max-w-2xl">
|
||||||
|
{collection.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Films */}
|
||||||
|
<div className="px-7 pt-2">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<h3 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">In order of release</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={gridCls}>
|
||||||
|
{parts.map((m, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => navigate(`/item/tmdb-${m.id}`)}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.04, 0.4) }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
className="group text-left focus-ring rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/40 transition-all duration-200">
|
||||||
|
{m.poster_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(m.poster_path, 'w300')}
|
||||||
|
alt={m.title}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
|
||||||
|
{m.title?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-1.5 left-1.5">
|
||||||
|
<span className="inline-flex items-center h-[18px] px-1.5 rounded text-[9px] font-bold bg-black/55 backdrop-blur text-white border border-white/10 tabular-nums">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[12.5px] font-medium text-text-1 truncate mt-2 leading-tight tracking-tight">
|
||||||
|
{m.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-3 truncate mt-0.5 inline-flex items-center gap-1">
|
||||||
|
<Calendar size={9} />
|
||||||
|
{m.release_date ? m.release_date.slice(0, 4) : 'TBA'}
|
||||||
|
</p>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionSkeleton() {
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<div className="relative h-[50vh] min-h-[380px] -mt-14 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 skeleton" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void to-void/30" />
|
||||||
|
<div className="relative h-full flex items-end pb-12 px-7">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="skeleton h-5 w-24 rounded" />
|
||||||
|
<div className="skeleton h-12 w-96 rounded" />
|
||||||
|
<div className="skeleton h-3 w-72 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-7 pt-6">
|
||||||
|
<div className={gridCls}>
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="skeleton h-3 w-3/4 mt-2 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 mt-1 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
useItemDetails,
|
||||||
|
useLibraryByTmdbId,
|
||||||
|
useRefreshItem,
|
||||||
|
useSampleEpisode,
|
||||||
|
useBulkToggleFavorite,
|
||||||
|
} from '../hooks/use-jellyfin'
|
||||||
|
import { useTmdbMovie, useTmdbTvShow, useTmdbCollection } from '../hooks/use-tmdb'
|
||||||
|
import {
|
||||||
|
useCinemeta,
|
||||||
|
useFanartMovie,
|
||||||
|
useFanartTv,
|
||||||
|
useTvmazeByImdbId,
|
||||||
|
useWikiResolve,
|
||||||
|
useWikiSection,
|
||||||
|
useWikidataAwards,
|
||||||
|
useWikidataLocations,
|
||||||
|
useRottenTomatoes,
|
||||||
|
} from '../hooks/use-external'
|
||||||
|
import { pickBestFanartImage } from '../api/fanart'
|
||||||
|
import DetailHero from '../components/detail/DetailHero'
|
||||||
|
import DetailStickyBar from '../components/detail/DetailStickyBar'
|
||||||
|
import type { SectionTab } from '../components/detail/DetailSectionTabs'
|
||||||
|
import DetailSkeleton from '../components/detail/DetailSkeleton'
|
||||||
|
import DetailMainSections from '../components/detail/DetailMainSections'
|
||||||
|
import { usePastSentinel } from '../hooks/use-past-sentinel'
|
||||||
|
import { parseTmdbRouteId } from '../lib/tmdb-mapping'
|
||||||
|
import TmdbDetailPage from './TmdbDetailPage'
|
||||||
|
import { getBestImage, getImageUrl, getStoredServerUrl } from '../api/jellyfin'
|
||||||
|
import PlaylistView from '../components/detail/PlaylistView'
|
||||||
|
import FolderView from '../components/detail/FolderView'
|
||||||
|
import { formatRuntime, regionForUser } from '../lib/format'
|
||||||
|
import { usePreferencesStore } from '../stores/preferences-store'
|
||||||
|
|
||||||
|
export default function DetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
// Synthetic TMDB ids from discovery rows route to a dedicated TMDB-only
|
||||||
|
// page so items not in the library get a proper detail view + Request CTA.
|
||||||
|
const tmdbRoute = parseTmdbRouteId(id)
|
||||||
|
if (tmdbRoute) return <TmdbDetailPage tmdbId={tmdbRoute.tmdbId} kind={tmdbRoute.kind} />
|
||||||
|
return <JellyfinDetailPage id={id} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function JellyfinDetailPage({ id }: { id?: string }) {
|
||||||
|
const { data: item, isLoading } = useItemDetails(id)
|
||||||
|
const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel()
|
||||||
|
const toggleFavorite = useBulkToggleFavorite()
|
||||||
|
|
||||||
|
const itemType = item?.Type
|
||||||
|
const tmdbId = item?.ProviderIds?.Tmdb
|
||||||
|
const imdbId = item?.ProviderIds?.Imdb || null
|
||||||
|
const tvdbId = item?.ProviderIds?.Tvdb || null
|
||||||
|
|
||||||
|
const tmdbMovie = useTmdbMovie(itemType === 'Movie' ? tmdbId : null)
|
||||||
|
const tmdbTv = useTmdbTvShow(itemType === 'Series' ? tmdbId : null)
|
||||||
|
const tmdbData = itemType === 'Movie' ? tmdbMovie.data : tmdbTv.data
|
||||||
|
|
||||||
|
// External enrichment - all keyless except Fanart.tv which silently no-ops
|
||||||
|
// when the personal key is empty. Each hook gates on type so we don't fire
|
||||||
|
// movie endpoints for shows or vice versa.
|
||||||
|
const fanartMovieQuery = useFanartMovie(itemType === 'Movie' ? (tmdbId || imdbId) : null)
|
||||||
|
const fanartTvQuery = useFanartTv(itemType === 'Series' ? tvdbId : null)
|
||||||
|
const cinemetaQuery = useCinemeta(
|
||||||
|
imdbId,
|
||||||
|
itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'series' : undefined,
|
||||||
|
)
|
||||||
|
const tvmazeQuery = useTvmazeByImdbId(itemType === 'Series' ? imdbId : null)
|
||||||
|
|
||||||
|
// Wikipedia overview fallback - resolved by title+year when neither
|
||||||
|
// Jellyfin/TMDB nor Cinemeta produced anything substantial. Hook must run
|
||||||
|
// unconditionally so it goes here, above any early returns.
|
||||||
|
const baseOverview = item?.Overview || tmdbData?.overview || ''
|
||||||
|
const overviewIsThin = baseOverview.trim().length < 60
|
||||||
|
const cinemetaDescription = cinemetaQuery.data?.description || ''
|
||||||
|
const wikiQuery = useWikiResolve(
|
||||||
|
item?.Name && item?.ProductionYear
|
||||||
|
? `${item.Name} ${item.ProductionYear}`
|
||||||
|
: item?.Name,
|
||||||
|
!!item && overviewIsThin && !cinemetaDescription,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wikipedia "Production" section, resolved unconditionally so the trivia
|
||||||
|
// block can render even when the overview is fine. We need a known title
|
||||||
|
// first; we always run a quick resolve for that and then ask for the
|
||||||
|
// section. Cheap because both queries are cached for a week.
|
||||||
|
const wikiTitleQuery = useWikiResolve(
|
||||||
|
item?.Name && item?.ProductionYear
|
||||||
|
? `${item.Name} ${item.ProductionYear}`
|
||||||
|
: item?.Name,
|
||||||
|
!!item,
|
||||||
|
)
|
||||||
|
const wikiTitle = wikiTitleQuery.data?.title || null
|
||||||
|
const wikiProductionQuery = useWikiSection(wikiTitle, 'Production')
|
||||||
|
|
||||||
|
// Wikidata awards via SPARQL P166. Keyed off the TMDB external_ids
|
||||||
|
// wikidata_id field which we already pull on movie + tv full responses.
|
||||||
|
const wikidataId = tmdbData?.external_ids?.wikidata_id || null
|
||||||
|
const awardsQuery = useWikidataAwards(wikidataId)
|
||||||
|
const locationsQuery = useWikidataLocations(wikidataId)
|
||||||
|
|
||||||
|
const collectionId = tmdbMovie.data?.belongs_to_collection?.id
|
||||||
|
const tmdbCollection = useTmdbCollection(collectionId)
|
||||||
|
|
||||||
|
// Rotten Tomatoes critic + audience scores (Algolia search by title+year,
|
||||||
|
// same approach Jellyseerr uses). Series ratings work, episode ratings
|
||||||
|
// would be hit-or-miss so we only fire on Movie / Series.
|
||||||
|
const rtKind: 'movie' | 'tv' | null =
|
||||||
|
itemType === 'Movie' ? 'movie' : itemType === 'Series' ? 'tv' : null
|
||||||
|
const rtYear = itemType === 'Movie'
|
||||||
|
? (tmdbMovie.data?.release_date ? Number(tmdbMovie.data.release_date.slice(0, 4)) : item?.ProductionYear) || null
|
||||||
|
: itemType === 'Series'
|
||||||
|
? (tmdbTv.data?.first_air_date ? Number(tmdbTv.data.first_air_date.slice(0, 4)) : item?.ProductionYear) || null
|
||||||
|
: null
|
||||||
|
const rtName =
|
||||||
|
itemType === 'Movie'
|
||||||
|
? tmdbMovie.data?.title || item?.Name
|
||||||
|
: itemType === 'Series'
|
||||||
|
? tmdbTv.data?.name || item?.Name
|
||||||
|
: null
|
||||||
|
const rtQuery = useRottenTomatoes(rtName, rtYear, rtKind)
|
||||||
|
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
const region = prefs.region || regionForUser()
|
||||||
|
const showTmdbRatings = prefs.showTmdbRatings
|
||||||
|
|
||||||
|
const libraryMap = useLibraryByTmdbId()
|
||||||
|
const refresh = useRefreshItem()
|
||||||
|
const sampleEpisode = useSampleEpisode(id, itemType === 'Series')
|
||||||
|
|
||||||
|
// Versions selector state
|
||||||
|
const sources = (item?.MediaSources || []) as any[]
|
||||||
|
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null)
|
||||||
|
const activeSourceId = selectedSourceId || sources[0]?.Id || null
|
||||||
|
|
||||||
|
// Pick a regional certification when available (must run before early returns)
|
||||||
|
const regionalCert = useMemo(() => {
|
||||||
|
if (itemType === 'Movie') {
|
||||||
|
const dates = (tmdbMovie.data?.release_dates?.results || []).find(r => r.iso_3166_1 === region)
|
||||||
|
return dates?.release_dates?.find(d => d.certification)?.certification || null
|
||||||
|
}
|
||||||
|
if (itemType === 'Series') {
|
||||||
|
const r = (tmdbTv.data?.content_ratings?.results || []).find(r => r.iso_3166_1 === region)
|
||||||
|
return r?.rating || null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [itemType, tmdbMovie.data, tmdbTv.data, region])
|
||||||
|
|
||||||
|
if (isLoading) return <DetailSkeleton />
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
|
||||||
|
<p className="text-text-2 text-[15px] mb-1">Item not found</p>
|
||||||
|
<p className="text-text-4 text-[12px]">The item you're looking for may have been removed.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
const jellyfinBackdrop = getBestImage(serverUrl, item, 'backdrop', 1920)
|
||||||
|
// Fanart backdrop fallback - useful for older / lesser-known items where
|
||||||
|
// the Jellyfin scraper didn't fetch a backdrop. Voted-best English wins.
|
||||||
|
const fanartBackdrop = pickBestFanartImage(
|
||||||
|
fanartMovieQuery.data?.moviebackground
|
||||||
|
|| fanartTvQuery.data?.showbackground
|
||||||
|
|| undefined,
|
||||||
|
)
|
||||||
|
const backdropUrl = jellyfinBackdrop || fanartBackdrop?.url || null
|
||||||
|
const posterUrl = getBestImage(serverUrl, item, 'primary', 400)
|
||||||
|
|
||||||
|
// Logo priority: Jellyfin Logo (locally curated) > Fanart.tv HD logo
|
||||||
|
// (cinematic SVG/PNG) > none (falls back to title typography).
|
||||||
|
const fanartLogo = pickBestFanartImage(
|
||||||
|
fanartMovieQuery.data?.hdmovielogo
|
||||||
|
|| fanartMovieQuery.data?.movielogo
|
||||||
|
|| fanartTvQuery.data?.hdtvlogo
|
||||||
|
|| fanartTvQuery.data?.clearlogo
|
||||||
|
|| undefined,
|
||||||
|
)
|
||||||
|
const jellyfinLogo =
|
||||||
|
item.Id && item.ImageTags?.Logo
|
||||||
|
? getImageUrl(serverUrl, item.Id, 'Logo', 600, item.ImageTags.Logo)
|
||||||
|
: null
|
||||||
|
const logoUrl = jellyfinLogo || fanartLogo?.url || null
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage
|
||||||
|
const positionTicks = item.UserData?.PlaybackPositionTicks
|
||||||
|
const resumeThresholdSec = prefs.resumeThresholdSec
|
||||||
|
const watchedSec = positionTicks ? Number(positionTicks) / 10_000_000 : 0
|
||||||
|
// Only offer resume when we've watched past the threshold (and we're not effectively done)
|
||||||
|
const meetsResumeThreshold = watchedSec >= resumeThresholdSec && (progress ?? 0) < 95
|
||||||
|
const resumeTime = positionTicks && meetsResumeThreshold ? formatRuntime(Number(positionTicks)) : null
|
||||||
|
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : null
|
||||||
|
|
||||||
|
const genres = item.Genres || []
|
||||||
|
const tmdbCredits = tmdbData?.credits
|
||||||
|
const cast = tmdbCredits?.cast?.slice(0, 18) || []
|
||||||
|
const crew = tmdbCredits?.crew || []
|
||||||
|
const trailer = tmdbData?.videos?.results?.find(
|
||||||
|
v => v.site === 'YouTube' && v.type === 'Trailer',
|
||||||
|
)
|
||||||
|
const hideAdult = prefs.hideAdult
|
||||||
|
const adultFilter = <T extends { adult?: boolean }>(arr: T[]) =>
|
||||||
|
hideAdult ? arr.filter(x => !x.adult) : arr
|
||||||
|
const recommendations = adultFilter((tmdbData?.recommendations?.results || []) as { id: number; adult?: boolean }[])
|
||||||
|
// Movie + TV similar arrays don't share an exact element type, so widen
|
||||||
|
// to the adultFilter constraint to keep TS happy across the union.
|
||||||
|
const similarRaw = itemType === 'Movie'
|
||||||
|
? tmdbMovie.data?.similar?.results || []
|
||||||
|
: tmdbTv.data?.similar?.results || []
|
||||||
|
const similar = adultFilter(similarRaw as { id: number; adult?: boolean }[])
|
||||||
|
const keywords =
|
||||||
|
itemType === 'Movie'
|
||||||
|
? tmdbMovie.data?.keywords?.keywords || []
|
||||||
|
: tmdbTv.data?.keywords?.results || []
|
||||||
|
const reviews = tmdbData?.reviews?.results || []
|
||||||
|
|
||||||
|
const title = item.Name || 'Untitled'
|
||||||
|
const year = item.ProductionYear
|
||||||
|
// Final overview chain (computed values - the source flags + base/thin
|
||||||
|
// were already established up at the top so the hook ordering is stable).
|
||||||
|
const overview = baseOverview
|
||||||
|
|| cinemetaDescription
|
||||||
|
|| wikiQuery.data?.extract
|
||||||
|
|| ''
|
||||||
|
const overviewSource = baseOverview
|
||||||
|
? null
|
||||||
|
: cinemetaDescription
|
||||||
|
? 'IMDB'
|
||||||
|
: wikiQuery.data
|
||||||
|
? 'Wikipedia'
|
||||||
|
: null
|
||||||
|
const taglineText = tmdbData?.tagline
|
||||||
|
|
||||||
|
const rating = regionalCert || item.OfficialRating
|
||||||
|
|
||||||
|
const isFavorite = item.UserData?.IsFavorite
|
||||||
|
|
||||||
|
const isFolder =
|
||||||
|
itemType === 'CollectionFolder' ||
|
||||||
|
itemType === 'ManualPlaylistsFolder' ||
|
||||||
|
itemType === 'PlaylistsFolder'
|
||||||
|
if (isFolder) {
|
||||||
|
return <FolderView itemId={id!} title={item.Name || 'Library'} collectionType={item.CollectionType} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlists get a purpose-built view: hero with Play / Shuffle, item list
|
||||||
|
// with thumbs and watched status. No tech specs, no episodes section.
|
||||||
|
if (itemType === 'Playlist') {
|
||||||
|
return <PlaylistView item={item} serverUrl={serverUrl} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const playUrl = `/play/${item.Id}${activeSourceId && sources.length > 1 ? `?source=${activeSourceId}` : ''}`
|
||||||
|
const resumeUrl = `/play/${item.Id}?resume=true${activeSourceId && sources.length > 1 ? `&source=${activeSourceId}` : ''}`
|
||||||
|
|
||||||
|
const watchProviders =
|
||||||
|
itemType === 'Movie' ? tmdbMovie.data?.['watch/providers'] : tmdbTv.data?.['watch/providers']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-16">
|
||||||
|
<DetailStickyBar
|
||||||
|
visible={pastHero}
|
||||||
|
title={title}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
posterUrl={posterUrl}
|
||||||
|
progress={progress}
|
||||||
|
isFavorite={isFavorite}
|
||||||
|
playUrl={playUrl}
|
||||||
|
resumeUrl={resumeUrl}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
imdbId={imdbId}
|
||||||
|
itemType={itemType}
|
||||||
|
tabs={detailTabsFor(itemType)}
|
||||||
|
onFavoriteToggle={
|
||||||
|
item.Id
|
||||||
|
? () => toggleFavorite.mutate({ itemIds: [item.Id!], favorite: !isFavorite })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailHero
|
||||||
|
item={item}
|
||||||
|
itemType={itemType ?? ""}
|
||||||
|
tmdbMovieData={tmdbMovie.data}
|
||||||
|
tmdbTvData={tmdbTv.data}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
posterUrl={posterUrl}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
backdropUrl={backdropUrl}
|
||||||
|
title={title}
|
||||||
|
year={year}
|
||||||
|
rating={rating}
|
||||||
|
runtime={runtime}
|
||||||
|
taglineText={taglineText}
|
||||||
|
genres={genres}
|
||||||
|
resumeTime={resumeTime}
|
||||||
|
progress={progress}
|
||||||
|
isFavorite={isFavorite}
|
||||||
|
playUrl={playUrl}
|
||||||
|
resumeUrl={resumeUrl}
|
||||||
|
trailer={trailer}
|
||||||
|
cinemetaData={cinemetaQuery.data}
|
||||||
|
showTmdbRatings={showTmdbRatings}
|
||||||
|
sampleEpisode={sampleEpisode}
|
||||||
|
refresh={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div ref={stickySentinelRef} aria-hidden className="h-px -mt-px" />
|
||||||
|
|
||||||
|
<DetailMainSections
|
||||||
|
item={item}
|
||||||
|
itemId={id}
|
||||||
|
itemType={itemType ?? ""}
|
||||||
|
imdbId={imdbId}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
tmdbMovieData={tmdbMovie.data}
|
||||||
|
tmdbTvData={tmdbTv.data}
|
||||||
|
tmdbCollectionData={tmdbCollection.data}
|
||||||
|
cinemetaData={cinemetaQuery.data}
|
||||||
|
tvmazeData={tvmazeQuery.data}
|
||||||
|
wikiProduction={wikiProductionQuery.data}
|
||||||
|
awardsData={awardsQuery.data}
|
||||||
|
locationsData={locationsQuery.data}
|
||||||
|
rtData={rtQuery.data}
|
||||||
|
wikiTitle={wikiTitle}
|
||||||
|
region={region}
|
||||||
|
watchProviders={watchProviders}
|
||||||
|
cast={cast}
|
||||||
|
crew={crew}
|
||||||
|
keywords={keywords}
|
||||||
|
reviews={reviews}
|
||||||
|
videos={tmdbData?.videos?.results}
|
||||||
|
recommendations={recommendations}
|
||||||
|
similar={similar}
|
||||||
|
libraryMap={libraryMap.data}
|
||||||
|
overview={overview}
|
||||||
|
overviewSource={overviewSource}
|
||||||
|
sources={sources}
|
||||||
|
activeSourceId={activeSourceId}
|
||||||
|
onSourceChange={setSelectedSourceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailTabsFor(itemType: string | undefined): SectionTab[] {
|
||||||
|
return [
|
||||||
|
{ id: 'detail-overview', label: 'Overview' },
|
||||||
|
...(itemType === 'Series' ? [{ id: 'detail-episodes', label: 'Episodes' } as SectionTab] : []),
|
||||||
|
{ id: 'detail-reception', label: 'Reception' },
|
||||||
|
{ id: 'detail-cast', label: 'Cast & crew' },
|
||||||
|
{ id: 'detail-trivia', label: 'Trivia' },
|
||||||
|
{ id: 'detail-reviews', label: 'Reviews' },
|
||||||
|
{ id: 'detail-more', label: 'More like this' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useHasTmdbKey } from '../hooks/use-external'
|
||||||
|
import LazyMount from '../components/ui/LazyMount'
|
||||||
|
import { usePreferencesStore } from '../stores/preferences-store'
|
||||||
|
import { regionForUser } from '../lib/format'
|
||||||
|
import DiscoverFilters, {
|
||||||
|
DEFAULT_FILTERS,
|
||||||
|
hasAnyActiveFilters,
|
||||||
|
type DiscoverFilterState,
|
||||||
|
} from '../components/discover/DiscoverFilters'
|
||||||
|
import TonightHero from '../components/discover/TonightHero'
|
||||||
|
import { MoodChips, MoodRow } from '../components/discover/MoodChips'
|
||||||
|
import Roulette from '../components/discover/Roulette'
|
||||||
|
import DecadeStrip from '../components/discover/DecadeStrip'
|
||||||
|
import CanonicalLists from '../components/discover/CanonicalLists'
|
||||||
|
import OnThisDay from '../components/discover/OnThisDay'
|
||||||
|
import LibraryGapFinder from '../components/discover/LibraryGapFinder'
|
||||||
|
import {
|
||||||
|
BrowseSection,
|
||||||
|
genreTiles,
|
||||||
|
languageTiles,
|
||||||
|
studioTiles,
|
||||||
|
networkTiles,
|
||||||
|
type BrowseKey,
|
||||||
|
} from '../components/discover/BrowseGrid'
|
||||||
|
import { NoKey, Hero } from './discover/chrome'
|
||||||
|
import { FilteredGrid } from './discover/filtered-grid'
|
||||||
|
import {
|
||||||
|
TrendingDayRow,
|
||||||
|
TrendingWeekRow,
|
||||||
|
PopularMoviesRow,
|
||||||
|
PopularTvRow,
|
||||||
|
UpcomingMoviesRow,
|
||||||
|
TopRatedMoviesRow,
|
||||||
|
TopRatedTvRow,
|
||||||
|
CultClassicsRow,
|
||||||
|
} from './discover/rows'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover page - intent-driven entry to TMDB content the user doesn't
|
||||||
|
* already have.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* 1. Compact page header
|
||||||
|
* 2. Sticky filter bar (movie/tv + advanced filters)
|
||||||
|
* 3. Editorial spotlight - one big "pick of the day"
|
||||||
|
* 4. Mood chips - 10 vibes that resolve to a single content row
|
||||||
|
* 5. 3-4 curated rows (Trending / Acclaimed / Coming soon / etc.)
|
||||||
|
* 6. Browse-by sections: genre, language, studio, network - compact
|
||||||
|
* tile grids with inline expansion when a tile is clicked
|
||||||
|
*
|
||||||
|
* Active filters take over the page with the existing FilteredGrid - that
|
||||||
|
* mode is unchanged so the filter workflow stays familiar.
|
||||||
|
*/
|
||||||
|
export default function DiscoverPage() {
|
||||||
|
const hasTmdb = useHasTmdbKey()
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
const region = prefs.region || regionForUser()
|
||||||
|
const [filters, setFilters] = useState<DiscoverFilterState>(() => ({
|
||||||
|
...DEFAULT_FILTERS,
|
||||||
|
watchRegion: region,
|
||||||
|
}))
|
||||||
|
const [moodId, setMoodId] = useState<string | null>(null)
|
||||||
|
const [decade, setDecade] = useState<string | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState<BrowseKey | null>(null)
|
||||||
|
|
||||||
|
if (!hasTmdb) return <NoKey />
|
||||||
|
|
||||||
|
const filterMode = hasAnyActiveFilters(filters)
|
||||||
|
const kind = filters.kind
|
||||||
|
|
||||||
|
// Reset the mood / expansion when toggling movie<->tv since both are
|
||||||
|
// kind-scoped and the previously-active mood may not exist on the
|
||||||
|
// other side.
|
||||||
|
function onFiltersChange(next: DiscoverFilterState) {
|
||||||
|
if (next.kind !== filters.kind) {
|
||||||
|
setMoodId(null)
|
||||||
|
setDecade(null)
|
||||||
|
setExpanded(null)
|
||||||
|
}
|
||||||
|
setFilters(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12" style={{ isolation: 'isolate' }}>
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sticky top-0 py-3 -mt-1 mb-3 border-b border-border/60"
|
||||||
|
style={{ zIndex: 25, backgroundColor: 'var(--color-void)' }}
|
||||||
|
>
|
||||||
|
<DiscoverFilters filters={filters} onChange={onFiltersChange} region={region} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filterMode ? (
|
||||||
|
<FilteredGrid filters={filters} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LazyMount><TonightHero kind={kind} /></LazyMount>
|
||||||
|
|
||||||
|
<div className="px-7 mb-2 flex items-center justify-end">
|
||||||
|
<Roulette kind={kind} moodId={moodId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MoodChips activeId={moodId} onChange={setMoodId} kind={kind} />
|
||||||
|
|
||||||
|
{moodId && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<MoodRow moodId={moodId} kind={kind} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DecadeStrip kind={kind} active={decade} onChange={setDecade} />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
{kind === 'movie' ? (
|
||||||
|
<>
|
||||||
|
<LazyMount><TrendingDayRow /></LazyMount>
|
||||||
|
<LazyMount><PopularMoviesRow /></LazyMount>
|
||||||
|
<LazyMount><UpcomingMoviesRow region={region} /></LazyMount>
|
||||||
|
<LazyMount><TopRatedMoviesRow /></LazyMount>
|
||||||
|
<LazyMount><CultClassicsRow /></LazyMount>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LazyMount><TrendingWeekRow /></LazyMount>
|
||||||
|
<LazyMount><PopularTvRow /></LazyMount>
|
||||||
|
<LazyMount><TopRatedTvRow /></LazyMount>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === 'movie' && <LazyMount><OnThisDay /></LazyMount>}
|
||||||
|
|
||||||
|
{kind === 'movie' && <LazyMount><LibraryGapFinder /></LazyMount>}
|
||||||
|
|
||||||
|
{kind === 'movie' && <LazyMount><CanonicalLists /></LazyMount>}
|
||||||
|
|
||||||
|
<div className="mt-4 mb-2 px-7">
|
||||||
|
<div className="flex items-center gap-3 text-text-4">
|
||||||
|
<span className="flex-1 h-px bg-border" />
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.2em] font-semibold">Browse by</span>
|
||||||
|
<span className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrowseSection
|
||||||
|
eyebrow="By genre"
|
||||||
|
title="Genres"
|
||||||
|
subtitle="Top of each genre, missing from your shelves"
|
||||||
|
tiles={genreTiles()}
|
||||||
|
expanded={expanded}
|
||||||
|
onSelect={setExpanded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{kind === 'movie' && (
|
||||||
|
<BrowseSection
|
||||||
|
eyebrow="By language"
|
||||||
|
title="Around the world"
|
||||||
|
subtitle="Top-rated cinema by original language"
|
||||||
|
tiles={languageTiles()}
|
||||||
|
expanded={expanded}
|
||||||
|
onSelect={setExpanded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'movie' && (
|
||||||
|
<BrowseSection
|
||||||
|
eyebrow="By studio"
|
||||||
|
title="From the studios"
|
||||||
|
subtitle="Films grouped by who made them"
|
||||||
|
tiles={studioTiles()}
|
||||||
|
expanded={expanded}
|
||||||
|
onSelect={setExpanded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'tv' && (
|
||||||
|
<BrowseSection
|
||||||
|
eyebrow="By network"
|
||||||
|
title="On the networks"
|
||||||
|
subtitle="Shows grouped by where they air"
|
||||||
|
tiles={networkTiles()}
|
||||||
|
expanded={expanded}
|
||||||
|
onSelect={setExpanded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Download, Trash2, Play, AlertCircle, Check, Clock } from '../lib/icons'
|
||||||
|
import { useDownloads } from '../stores/downloads-store'
|
||||||
|
import { isTauri } from '../lib/tauri'
|
||||||
|
|
||||||
|
export default function DownloadsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const items = useDownloads(s => s.items)
|
||||||
|
const remove = useDownloads(s => s.remove)
|
||||||
|
const clearCompleted = useDownloads(s => s.clearCompleted)
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const active = items.filter(i => i.status === 'queued' || i.status === 'downloading')
|
||||||
|
const done = items.filter(i => i.status === 'done')
|
||||||
|
const error = items.filter(i => i.status === 'error')
|
||||||
|
return { active, done, error }
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
<header className="mb-7 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Offline</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||||
|
Downloads
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
|
||||||
|
{isTauri
|
||||||
|
? 'Items saved locally for offline playback.'
|
||||||
|
: 'Downloads are stored in the browser cache for offline playback.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{grouped.done.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearCompleted}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3 rounded-md text-[12px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-error/50 hover:text-error transition-all focus-ring"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} stroke={2} />
|
||||||
|
Clear completed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
|
||||||
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||||
|
<Download size={20} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No downloads yet</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
||||||
|
Use the download button in the player to save items for offline viewing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{grouped.active.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
||||||
|
<Clock size={13} className="text-accent" /> In progress
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{grouped.active.map(item => (
|
||||||
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grouped.done.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
||||||
|
<Check size={13} className="text-success" /> Completed
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{grouped.done.map(item => (
|
||||||
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} onPlay={() => navigate(`/play/${item.itemId}`)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{grouped.error.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight flex items-center gap-2">
|
||||||
|
<AlertCircle size={13} className="text-error" /> Failed
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{grouped.error.map(item => (
|
||||||
|
<DownloadRow key={item.id} item={item} onRemove={() => remove(item.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadRow({
|
||||||
|
item,
|
||||||
|
onRemove,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
item: import('../stores/downloads-store').DownloadItem
|
||||||
|
onRemove: () => void
|
||||||
|
onPlay?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl bg-elevated/30 ring-1 ring-border hover:ring-border-strong transition"
|
||||||
|
>
|
||||||
|
{item.posterUrl ? (
|
||||||
|
<img src={item.posterUrl} alt="" className="w-10 aspect-[2/3] rounded object-cover shrink-0 bg-void" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 aspect-[2/3] rounded bg-void grid place-items-center shrink-0">
|
||||||
|
<Download size={14} className="text-text-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] text-text-1 font-medium truncate">{item.name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{item.status === 'downloading' && (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-elevated overflow-hidden max-w-[160px]">
|
||||||
|
<div className="h-full bg-accent transition-[width] duration-300" style={{ width: `${item.progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10.5px] text-text-3 tabular-nums">{item.progress}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.status === 'done' && <span className="text-[10.5px] text-success font-medium">Ready</span>}
|
||||||
|
{item.status === 'error' && <span className="text-[10.5px] text-error font-medium truncate">{item.error || 'Failed'}</span>}
|
||||||
|
{item.status === 'queued' && <span className="text-[10.5px] text-text-3 font-medium">Queued</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{onPlay && (
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
|
||||||
|
aria-label="Play"
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-error hover:bg-elevated transition focus-ring"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Film, Tv, AlertCircle } from '../lib/icons'
|
||||||
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
|
||||||
|
import PosterCard from '../components/ui/PosterCard'
|
||||||
|
|
||||||
|
export default function DuplicatesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
const { data, isLoading } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['SortName'],
|
||||||
|
sortOrder: ['Ascending'],
|
||||||
|
limit: 5000,
|
||||||
|
})
|
||||||
|
const items = data?.Items || []
|
||||||
|
|
||||||
|
const duplicates = useMemo(() => {
|
||||||
|
// Group by TMDB ID first (most reliable)
|
||||||
|
const byTmdb = new Map<string, typeof items>()
|
||||||
|
const byNameYear = new Map<string, typeof items>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const tmdb = item.ProviderIds?.Tmdb
|
||||||
|
if (tmdb) {
|
||||||
|
const list = byTmdb.get(String(tmdb)) || []
|
||||||
|
list.push(item)
|
||||||
|
byTmdb.set(String(tmdb), list)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Fallback: normalized name + year + type
|
||||||
|
const key = `${(item.Name || '').toLowerCase().trim()}|${item.ProductionYear || 'none'}|${item.Type}`
|
||||||
|
const list = byNameYear.get(key) || []
|
||||||
|
list.push(item)
|
||||||
|
byNameYear.set(key, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: { key: string; label: string; items: typeof items }[] = []
|
||||||
|
for (const [tmdb, list] of byTmdb) {
|
||||||
|
if (list.length >= 2) {
|
||||||
|
const first = list[0]
|
||||||
|
groups.push({
|
||||||
|
key: `tmdb-${tmdb}`,
|
||||||
|
label: `${first.Name} (${first.ProductionYear || '?'})`,
|
||||||
|
items: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, list] of byNameYear) {
|
||||||
|
if (list.length >= 2) {
|
||||||
|
const first = list[0]
|
||||||
|
groups.push({
|
||||||
|
key: `name-${first.Id}`,
|
||||||
|
label: `${first.Name} (${first.ProductionYear || '?'}) - no TMDB match`,
|
||||||
|
items: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
<header className="mb-7">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Tools</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||||
|
Duplicate finder
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
|
||||||
|
Scans your library for items that appear more than once - either by shared TMDB ID or by matching name + year.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && items.length === 0 && (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
||||||
|
<p className="text-[13px] text-text-2 font-medium">Scanning your library...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{duplicates.length === 0 && !isLoading && (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
|
||||||
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
|
||||||
|
<AlertCircle size={20} className="text-success" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No duplicates found</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
||||||
|
Every movie and series in your library has a unique identity. Nice and tidy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{duplicates.map((group, gi) => (
|
||||||
|
<section key={group.key}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{group.items[0]?.Type === 'Series' ? (
|
||||||
|
<Tv size={13} className="text-accent" />
|
||||||
|
) : (
|
||||||
|
<Film size={13} className="text-accent" />
|
||||||
|
)}
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 tracking-tight">{group.label}</h2>
|
||||||
|
<span className="text-[11px] text-text-4 tabular-nums">{group.items.length} copies</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
||||||
|
{group.items.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.Id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
|
||||||
|
>
|
||||||
|
<PosterCard
|
||||||
|
item={item}
|
||||||
|
priority={gi < 3 && i < 6}
|
||||||
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useHomeData, useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import { useTmdbTrending } from '../hooks/use-tmdb'
|
||||||
|
import ContentRow from '../components/ui/ContentRow'
|
||||||
|
import LazyMount, { RowBoundary } from '../components/ui/LazyMount'
|
||||||
|
import { getTmdbImageUrl } from '../api/tmdb'
|
||||||
|
import { usePreferencesStore } from '../stores/preferences-store'
|
||||||
|
import type { BaseItemDto } from '../api/types'
|
||||||
|
import { FeaturedCarousel } from './home/home-hero'
|
||||||
|
import { dailyBucket, seededShuffle, HomeSkeleton, EmptyHome } from './home/home-utils'
|
||||||
|
import { DecadeRow, GenreRow } from './home/rows/library'
|
||||||
|
import { HomeSections } from './home/rows/composite'
|
||||||
|
|
||||||
|
const FEATURED_GENRES = [
|
||||||
|
{ name: 'Action', subtitle: 'Bullets, fists, explosions' },
|
||||||
|
{ name: 'Drama', subtitle: 'Stories that hit deep' },
|
||||||
|
{ name: 'Comedy', subtitle: 'Light watching' },
|
||||||
|
{ name: 'Science Fiction', subtitle: 'Beyond the possible' },
|
||||||
|
{ name: 'Thriller', subtitle: 'Edge-of-your-seat tension' },
|
||||||
|
{ name: 'Horror', subtitle: 'Lights off' },
|
||||||
|
{ name: 'Documentary', subtitle: 'Real stories' },
|
||||||
|
{ name: 'Animation', subtitle: 'Drawn to perfection' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { continueWatching, nextUp, recentlyAdded } = useHomeData()
|
||||||
|
const trending = useTmdbTrending('all', 'week')
|
||||||
|
const hideAdult = usePreferencesStore(s => s.hideAdult)
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
|
||||||
|
const topRated = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['CommunityRating'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
minCommunityRating: 7,
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const surpriseMe = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['Random'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const recentlyPlayed = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
const featuredPool = useMemo<BaseItemDto[]>(() => {
|
||||||
|
const pool: BaseItemDto[] = []
|
||||||
|
const isFeatureWorthy = (item: BaseItemDto) =>
|
||||||
|
(item.Type === 'Movie' || item.Type === 'Series') &&
|
||||||
|
!!item.BackdropImageTags?.length
|
||||||
|
|
||||||
|
// Bias toward continue watching first (user is engaged with these)
|
||||||
|
for (const item of continueWatching.data || []) if (isFeatureWorthy(item)) pool.push(item)
|
||||||
|
for (const item of recentlyAdded.data || []) if (isFeatureWorthy(item)) pool.push(item)
|
||||||
|
for (const item of topRated.data?.Items || []) if (isFeatureWorthy(item)) pool.push(item)
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const unique = pool.filter(p => {
|
||||||
|
if (!p.Id || seen.has(p.Id)) return false
|
||||||
|
seen.add(p.Id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Day-bucketed shuffle so the order changes a few times a day but stays
|
||||||
|
// stable within a session, then clamp to 8 slides max.
|
||||||
|
return seededShuffle(unique, dailyBucket()).slice(0, 8)
|
||||||
|
}, [continueWatching.data, recentlyAdded.data, topRated.data])
|
||||||
|
|
||||||
|
const hasAnyData =
|
||||||
|
(continueWatching.data?.length ?? 0) > 0 ||
|
||||||
|
(nextUp.data?.length ?? 0) > 0 ||
|
||||||
|
(recentlyAdded.data?.length ?? 0) > 0
|
||||||
|
|
||||||
|
const allLoading =
|
||||||
|
continueWatching.isLoading && nextUp.isLoading && recentlyAdded.isLoading
|
||||||
|
|
||||||
|
if (allLoading) return <HomeSkeleton />
|
||||||
|
|
||||||
|
if (!hasAnyData) return <EmptyHome />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
{featuredPool.length > 0 && <FeaturedCarousel items={featuredPool} navigate={navigate} />}
|
||||||
|
|
||||||
|
<div className="-mt-4 relative z-10">
|
||||||
|
{prefs.home.show.continueWatching && continueWatching.data && continueWatching.data.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Continue watching"
|
||||||
|
subtitle="Pick up where you left off"
|
||||||
|
items={continueWatching.data}
|
||||||
|
aspect="landscape"
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.nextUp && nextUp.data && nextUp.data.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Next up"
|
||||||
|
subtitle="The next episodes for shows you watch"
|
||||||
|
items={nextUp.data}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.recentlyAdded && recentlyAdded.data && recentlyAdded.data.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Recently added"
|
||||||
|
subtitle="Fresh from your library"
|
||||||
|
items={recentlyAdded.data}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.trendingWeek && trending.data?.results && trending.data.results.length > 0 && (() => {
|
||||||
|
const filtered = trending.data.results.filter(r => !hideAdult || !r.adult)
|
||||||
|
if (filtered.length === 0) return null
|
||||||
|
return (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Trending this week"
|
||||||
|
subtitle="What the world is watching on TMDB"
|
||||||
|
items={filtered.map(r => ({
|
||||||
|
Id: `tmdb-${r.id}`,
|
||||||
|
Name: r.title || r.name,
|
||||||
|
Type: r.media_type === 'tv' ? 'Series' : 'Movie',
|
||||||
|
ProductionYear: r.release_date
|
||||||
|
? parseInt(r.release_date)
|
||||||
|
: r.first_air_date
|
||||||
|
? parseInt(r.first_air_date)
|
||||||
|
: undefined,
|
||||||
|
ImageTags: {},
|
||||||
|
ProviderIds: { Tmdb: String(r.id) },
|
||||||
|
_tmdbPoster: r.poster_path ? getTmdbImageUrl(r.poster_path, 'w342') : undefined,
|
||||||
|
} as any as BaseItemDto))}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{prefs.home.show.topRated && topRated.data?.Items && topRated.data.Items.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Top rated in your library"
|
||||||
|
subtitle="The cream of the crop"
|
||||||
|
items={topRated.data.Items}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.watchedRecently && recentlyPlayed.data?.Items && recentlyPlayed.data.Items.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Watched recently"
|
||||||
|
subtitle="Revisit something you've finished"
|
||||||
|
items={recentlyPlayed.data.Items}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.surpriseMe && surpriseMe.data?.Items && surpriseMe.data.Items.length > 0 && (
|
||||||
|
<RowBoundary>
|
||||||
|
<ContentRow
|
||||||
|
title="Surprise me"
|
||||||
|
subtitle="A random shake-up from your library"
|
||||||
|
items={surpriseMe.data.Items}
|
||||||
|
/>
|
||||||
|
</RowBoundary>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HomeSections />
|
||||||
|
|
||||||
|
{prefs.home.show.decadeRows && (
|
||||||
|
<>
|
||||||
|
<LazyMount><DecadeRow decade={1990} /></LazyMount>
|
||||||
|
<LazyMount><DecadeRow decade={2000} /></LazyMount>
|
||||||
|
<LazyMount><DecadeRow decade={2010} /></LazyMount>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.home.show.featuredGenres &&
|
||||||
|
FEATURED_GENRES.map(g => (
|
||||||
|
<LazyMount key={g.name}>
|
||||||
|
<GenreRow genre={g.name} subtitle={g.subtitle} />
|
||||||
|
</LazyMount>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { ArrowDownAZ, Calendar, Shuffle, Film, Tv, Filter, X, Star } from '../lib/icons'
|
||||||
|
import { useLibraries, useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import PosterCard from '../components/ui/PosterCard'
|
||||||
|
import Select, { type SelectOption } from '../components/ui/Select'
|
||||||
|
import { resolutionLabel, videoRangeLabel } from '../lib/jellyfin-meta'
|
||||||
|
import { iconForGenre } from '../lib/genre-icons'
|
||||||
|
import { usePosterGridClasses } from '../lib/density'
|
||||||
|
import { type SavedSearchFilters } from '../stores/saved-searches-store'
|
||||||
|
import { useLibrarySelection } from '../stores/library-selection-store'
|
||||||
|
import BatchActionsBar from '../components/ui/BatchActionsBar'
|
||||||
|
import { WatchedPills, SavedSearchMenu } from './library/controls'
|
||||||
|
import { SaveSearchModal, SurpriseMeModal } from './library/modals'
|
||||||
|
import { EmptyLibrary, PosterGridSkeleton } from './library/states'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: 'movies' | 'shows'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: 'SortName', label: 'A - Z', icon: ArrowDownAZ },
|
||||||
|
{ value: 'DateCreated', label: 'Recently added', icon: Calendar },
|
||||||
|
{ value: 'PremiereDate', label: 'Release date', icon: Calendar },
|
||||||
|
{ value: 'CommunityRating', label: 'Rating', icon: Star },
|
||||||
|
{ value: 'Random', label: 'Random', icon: Shuffle },
|
||||||
|
]
|
||||||
|
|
||||||
|
const COLLECTION_TYPE_MAP: Record<string, string> = {
|
||||||
|
movies: 'movies',
|
||||||
|
shows: 'tvshows',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM_TYPE_MAP: Record<string, string[]> = {
|
||||||
|
movies: ['Movie'],
|
||||||
|
shows: ['Series'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECADE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: '__any__', label: 'Any decade' },
|
||||||
|
{ value: '2020', label: '2020s' },
|
||||||
|
{ value: '2010', label: '2010s' },
|
||||||
|
{ value: '2000', label: '2000s' },
|
||||||
|
{ value: '1990', label: '1990s' },
|
||||||
|
{ value: '1980', label: '1980s' },
|
||||||
|
{ value: '1970', label: '1970s' },
|
||||||
|
{ value: '1960', label: '1960s' },
|
||||||
|
{ value: '1950', label: '1950s' },
|
||||||
|
{ value: '1900', label: 'Pre-1950' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LibraryPage({ type }: Props) {
|
||||||
|
const [sortBy, setSortBy] = useState('SortName')
|
||||||
|
const [genreFilter, setGenreFilter] = useState<string | null>(null)
|
||||||
|
const [decade, setDecade] = useState<number | null>(null)
|
||||||
|
const [watchedFilter, setWatchedFilter] = useState<'any' | 'played' | 'unplayed'>('any')
|
||||||
|
const [only4K, setOnly4K] = useState(false)
|
||||||
|
const [onlyHdr, setOnlyHdr] = useState(false)
|
||||||
|
const [surpriseOpen, setSurpriseOpen] = useState(false)
|
||||||
|
const [saveOpen, setSaveOpen] = useState(false)
|
||||||
|
const [layoutMode, setLayoutMode] = useState<'grid' | 'map'>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'grid'
|
||||||
|
return (localStorage.getItem(`lib_layout:${type}`) as 'grid' | 'map') || 'grid'
|
||||||
|
})
|
||||||
|
const [mapShowAll, setMapShowAll] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
const mapGridCls = 'grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-12 xl:grid-cols-14 2xl:grid-cols-16 gap-2'
|
||||||
|
const selected = useLibrarySelection(s => s.selected)
|
||||||
|
const toggleSelect = useLibrarySelection(s => s.toggle)
|
||||||
|
const clearSelection = useLibrarySelection(s => s.clear)
|
||||||
|
|
||||||
|
function changeLayoutMode(next: 'grid' | 'map') {
|
||||||
|
setLayoutMode(next)
|
||||||
|
try { localStorage.setItem(`lib_layout:${type}`, next) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any in-progress selection when navigating away from the page
|
||||||
|
// or pressing Escape. Esc is the same dismiss affordance the playlist
|
||||||
|
// view uses, so the muscle memory carries.
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && selected.size > 0) {
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [selected.size, clearSelection])
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearSelection()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { data: libraries } = useLibraries()
|
||||||
|
const collectionType = COLLECTION_TYPE_MAP[type]
|
||||||
|
const library = libraries?.find(l => l.CollectionType === collectionType)
|
||||||
|
const parentId = library?.Id
|
||||||
|
const includeItemTypes = ITEM_TYPE_MAP[type]
|
||||||
|
|
||||||
|
const filtersForApi = useMemo(() => {
|
||||||
|
const f: string[] = []
|
||||||
|
if (watchedFilter === 'played') f.push('IsPlayed')
|
||||||
|
else if (watchedFilter === 'unplayed') f.push('IsUnplayed')
|
||||||
|
return f.length > 0 ? f : undefined
|
||||||
|
}, [watchedFilter])
|
||||||
|
|
||||||
|
const yearsForApi = useMemo(() => {
|
||||||
|
if (decade == null) return undefined
|
||||||
|
if (decade === 1900) {
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let y = 1900; y < 1950; y++) arr.push(y)
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
return Array.from({ length: 10 }, (_, i) => decade + i)
|
||||||
|
}, [decade])
|
||||||
|
|
||||||
|
const sortOrder = useMemo(() => {
|
||||||
|
if (sortBy === 'Random') return ['Descending']
|
||||||
|
if (sortBy === 'CommunityRating' || sortBy === 'DateCreated' || sortBy === 'PremiereDate') {
|
||||||
|
return ['Descending']
|
||||||
|
}
|
||||||
|
return ['Ascending']
|
||||||
|
}, [sortBy])
|
||||||
|
|
||||||
|
const { data, isLoading } = useLibraryItems(parentId, {
|
||||||
|
sortBy: [sortBy],
|
||||||
|
sortOrder,
|
||||||
|
includeItemTypes,
|
||||||
|
filters: filtersForApi,
|
||||||
|
years: yearsForApi,
|
||||||
|
limit: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
const allItems = useMemo(() => data?.Items || [], [data?.Items])
|
||||||
|
|
||||||
|
const allGenres = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const it of allItems) {
|
||||||
|
for (const g of it.Genres || []) set.add(g)
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b))
|
||||||
|
}, [allItems])
|
||||||
|
|
||||||
|
const items = useMemo(() => {
|
||||||
|
return allItems.filter(it => {
|
||||||
|
if (genreFilter && !(it.Genres || []).includes(genreFilter)) return false
|
||||||
|
if (only4K && resolutionLabel(it as any) !== '4K') return false
|
||||||
|
if (onlyHdr && !videoRangeLabel(it as any)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [allItems, genreFilter, only4K, onlyHdr])
|
||||||
|
|
||||||
|
const totalCount = items.length
|
||||||
|
const filtered = !!(genreFilter || only4K || onlyHdr || decade != null || watchedFilter !== 'any')
|
||||||
|
const Icon = type === 'movies' ? Film : Tv
|
||||||
|
|
||||||
|
const currentFilters: SavedSearchFilters = {
|
||||||
|
sortBy,
|
||||||
|
watched: watchedFilter,
|
||||||
|
genre: genreFilter,
|
||||||
|
decade,
|
||||||
|
only4K,
|
||||||
|
onlyHdr,
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySaved(filters: SavedSearchFilters) {
|
||||||
|
setSortBy(filters.sortBy)
|
||||||
|
setWatchedFilter(filters.watched)
|
||||||
|
setGenreFilter(filters.genre)
|
||||||
|
setDecade(filters.decade)
|
||||||
|
setOnly4K(filters.only4K)
|
||||||
|
setOnlyHdr(filters.onlyHdr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
setGenreFilter(null)
|
||||||
|
setDecade(null)
|
||||||
|
setWatchedFilter('any')
|
||||||
|
setOnly4K(false)
|
||||||
|
setOnlyHdr(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-7">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||||
|
{type === 'movies' ? 'Movies' : 'TV shows'}
|
||||||
|
</h1>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="text-[12px] text-text-4 tabular-nums">
|
||||||
|
{totalCount.toLocaleString()} {totalCount === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort chips */}
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-elevated/50 border border-border rounded-lg w-fit">
|
||||||
|
{SORT_OPTIONS.map(opt => {
|
||||||
|
const isActive = sortBy === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setSortBy(opt.value)}
|
||||||
|
className={`relative h-7 px-2.5 rounded-md text-[11.5px] font-medium tracking-tight transition-colors duration-150 flex items-center gap-1.5 focus-ring ${
|
||||||
|
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="sort-active"
|
||||||
|
className="absolute inset-0 bg-accent rounded-md shadow-sm"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<opt.icon size={12} className="relative" strokeWidth={2.25} />
|
||||||
|
<span className="relative hidden sm:inline">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6 flex items-center gap-2 flex-wrap">
|
||||||
|
<WatchedPills value={watchedFilter} onChange={setWatchedFilter} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setOnly4K(o => !o)}
|
||||||
|
className={`inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[11px] font-bold uppercase tracking-wider transition-all duration-150 focus-ring ${
|
||||||
|
only4K
|
||||||
|
? 'bg-cool/25 text-cool border border-cool/40'
|
||||||
|
: 'bg-elevated/50 text-text-3 border border-border hover:text-text-1 hover:border-border-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
4K
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOnlyHdr(o => !o)}
|
||||||
|
className={`inline-flex items-center gap-1 h-7 px-2.5 rounded-md text-[11px] font-bold uppercase tracking-wider transition-all duration-150 focus-ring ${
|
||||||
|
onlyHdr
|
||||||
|
? 'bg-amber-500/25 text-amber-200 border border-amber-400/40'
|
||||||
|
: 'bg-elevated/50 text-text-3 border border-border hover:text-text-1 hover:border-border-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
HDR/DV
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="Filter by decade"
|
||||||
|
triggerIcon={<Calendar size={11} stroke={2} />}
|
||||||
|
value={decade == null ? '__any__' : String(decade)}
|
||||||
|
onChange={v => setDecade(v === '__any__' ? null : Number(v))}
|
||||||
|
width="min-w-[120px]"
|
||||||
|
options={DECADE_OPTIONS as SelectOption<string>[]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{allGenres.length > 0 && (
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="Filter by genre"
|
||||||
|
triggerIcon={<Filter size={11} stroke={2} />}
|
||||||
|
value={genreFilter ?? '__all__'}
|
||||||
|
onChange={v => setGenreFilter(v === '__all__' ? null : v)}
|
||||||
|
width="min-w-[160px]"
|
||||||
|
options={[
|
||||||
|
{ value: '__all__', label: 'All genres', muted: true } as SelectOption<string>,
|
||||||
|
...allGenres.map<SelectOption<string>>(g => ({
|
||||||
|
value: g,
|
||||||
|
label: g,
|
||||||
|
icon: iconForGenre(g),
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-md text-[11px] text-text-3 hover:text-text-1 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SavedSearchMenu
|
||||||
|
scope={type}
|
||||||
|
onApply={applySaved}
|
||||||
|
onSave={() => setSaveOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSurpriseOpen(true)}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-[11.5px] font-medium tracking-tight bg-accent/10 text-accent ring-1 ring-accent/30 hover:bg-accent/15 transition disabled:opacity-40 focus-ring"
|
||||||
|
>
|
||||||
|
<Shuffle size={11} stroke={2.25} />
|
||||||
|
Surprise me
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{filtered && (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-text-4">
|
||||||
|
<Filter size={10} className="text-accent" />
|
||||||
|
<span className="tabular-nums">{totalCount}</span> match{totalCount === 1 ? '' : 'es'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout toggle - sits next to the matches counter */}
|
||||||
|
<div className="flex items-center justify-end gap-1 mb-3 -mt-3">
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-4 mr-1">
|
||||||
|
View
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex items-center bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => changeLayoutMode('grid')}
|
||||||
|
className={`h-6 px-2.5 rounded text-[11px] font-medium tracking-tight transition focus-ring ${
|
||||||
|
layoutMode === 'grid' ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Grid
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => changeLayoutMode('map')}
|
||||||
|
className={`h-6 px-2.5 rounded text-[11px] font-medium tracking-tight transition focus-ring ${
|
||||||
|
layoutMode === 'map' ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
{isLoading ? (
|
||||||
|
<PosterGridSkeleton />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyLibrary type={type} Icon={Icon} />
|
||||||
|
) : (
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
<motion.div
|
||||||
|
key={`${sortBy}-${layoutMode}`}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className={layoutMode === 'map' ? mapGridCls : gridCls}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Click on the grid background (not on a card) clears the selection.
|
||||||
|
if (e.target === e.currentTarget && selected.size > 0) clearSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(layoutMode === 'map' && !mapShowAll ? items.slice(0, 240) : items).map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.Id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.3), ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<PosterCard
|
||||||
|
item={item}
|
||||||
|
priority={i < 12}
|
||||||
|
selected={item.Id ? selected.has(item.Id) : false}
|
||||||
|
onToggleSelect={item.Id ? () => toggleSelect(item.Id!) : undefined}
|
||||||
|
libraryContext
|
||||||
|
compact={layoutMode === 'map'}
|
||||||
|
onClick={() => {
|
||||||
|
if (selected.size > 0) {
|
||||||
|
// While in selection mode, plain clicks toggle membership too.
|
||||||
|
if (item.Id) toggleSelect(item.Id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.Id) navigate(`/item/${item.Id}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{layoutMode === 'map' && !mapShowAll && items.length > 240 && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setMapShowAll(true)}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-4 rounded-full bg-elevated/60 ring-1 ring-border hover:ring-accent/40 hover:text-accent text-[12px] text-text-2 font-medium tracking-tight transition focus-ring"
|
||||||
|
>
|
||||||
|
Load all {items.length} items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BatchActionsBar />
|
||||||
|
|
||||||
|
<SurpriseMeModal
|
||||||
|
open={surpriseOpen}
|
||||||
|
items={items}
|
||||||
|
onClose={() => setSurpriseOpen(false)}
|
||||||
|
/>
|
||||||
|
<SaveSearchModal
|
||||||
|
open={saveOpen}
|
||||||
|
scope={type}
|
||||||
|
filters={currentFilters}
|
||||||
|
onClose={() => setSaveOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Radio, CalendarEvent, FileVideo, Play, Clock } from '../lib/icons'
|
||||||
|
import { useLiveTvInfo, useLiveTvChannels, useLiveTvRecordings, useLiveTvTimers } from '../hooks/use-jellyfin'
|
||||||
|
import { jellyfinClient, getImageUrl } from '../api/jellyfin'
|
||||||
|
|
||||||
|
type Tab = 'channels' | 'recordings' | 'scheduled'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live TV / DVR landing page. Three tabs surfaced off Jellyfin's
|
||||||
|
* LiveTv API:
|
||||||
|
* - Channels (with current-program inline)
|
||||||
|
* - Recordings (past + in-progress)
|
||||||
|
* - Scheduled timers
|
||||||
|
*
|
||||||
|
* If Live TV isn't configured server-side, the page shows a friendly
|
||||||
|
* placeholder instead of empty lists.
|
||||||
|
*/
|
||||||
|
export default function LiveTvPage() {
|
||||||
|
const info = useLiveTvInfo()
|
||||||
|
const [tab, setTab] = useState<Tab>('channels')
|
||||||
|
|
||||||
|
const enabled = info.data?.IsEnabled !== false
|
||||||
|
const noTuners = !info.data?.Services?.length
|
||||||
|
|
||||||
|
if (!info.isLoading && (!enabled || noTuners)) {
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-6 pb-12">
|
||||||
|
<Header />
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center max-w-xl mx-auto mt-10">
|
||||||
|
<Radio size={28} className="text-text-3 mx-auto mb-3" />
|
||||||
|
<p className="text-[14px] text-text-1 font-semibold mb-1.5">Live TV isn't set up on this server</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 leading-relaxed">
|
||||||
|
Add a tuner and a guide provider in the Jellyfin dashboard under Live TV. Once configured, your
|
||||||
|
channels and DVR recordings will show up here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-6 pb-12">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 mb-6 border-b border-border">
|
||||||
|
<TabButton active={tab === 'channels'} onClick={() => setTab('channels')} icon={<Radio size={14} />}>
|
||||||
|
Channels
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === 'recordings'} onClick={() => setTab('recordings')} icon={<FileVideo size={14} />}>
|
||||||
|
Recordings
|
||||||
|
</TabButton>
|
||||||
|
<TabButton active={tab === 'scheduled'} onClick={() => setTab('scheduled')} icon={<CalendarEvent size={14} />}>
|
||||||
|
Scheduled
|
||||||
|
</TabButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'channels' && <ChannelsList />}
|
||||||
|
{tab === 'recordings' && <RecordingsList />}
|
||||||
|
{tab === 'scheduled' && <ScheduledList />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
return (
|
||||||
|
<header className="mb-6">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
||||||
|
Live TV
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
|
||||||
|
Channels & recordings
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
icon: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`relative px-4 h-10 inline-flex items-center gap-2 text-[12.5px] font-medium tracking-tight transition-colors focus-ring ${
|
||||||
|
active ? 'text-text-1' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="livetv-tab"
|
||||||
|
className="absolute -bottom-px left-0 right-0 h-[2px] bg-accent rounded-t"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelsList() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
|
||||||
|
const { data: channels = [], isLoading } = useLiveTvChannels()
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (channels.length === 0) {
|
||||||
|
return <EmptyMsg>No channels available right now.</EmptyMsg>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{channels.map(ch => {
|
||||||
|
const program = (ch as any).CurrentProgram as { Name?: string; StartDate?: string; EndDate?: string } | undefined
|
||||||
|
const imageTag = ch.ImageTags?.Primary
|
||||||
|
const img = ch.Id && imageTag ? getImageUrl(serverUrl, ch.Id, 'Primary', 128, imageTag) : ''
|
||||||
|
return (
|
||||||
|
<li key={ch.Id}>
|
||||||
|
<button
|
||||||
|
onClick={() => ch.Id && navigate(`/play/${ch.Id}`)}
|
||||||
|
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-md bg-void/50 grid place-items-center shrink-0 overflow-hidden">
|
||||||
|
{img ? (
|
||||||
|
<img src={img} alt="" className="w-full h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<Radio size={18} className="text-text-3" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate flex items-baseline gap-2">
|
||||||
|
{ch.ChannelNumber && (
|
||||||
|
<span className="text-[11px] text-text-4 tabular-nums shrink-0">{ch.ChannelNumber}</span>
|
||||||
|
)}
|
||||||
|
{ch.Name || 'Channel'}
|
||||||
|
</p>
|
||||||
|
{program?.Name ? (
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
|
||||||
|
{programTime(program.StartDate, program.EndDate)}{' '}<span className="text-text-2">{program.Name}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11.5px] text-text-4 italic mt-0.5">No program info</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Play size={14} className="text-accent shrink-0" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingsList() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverUrl = jellyfinClient.getAuthState()?.serverUrl || ''
|
||||||
|
const { data: recordings = [], isLoading } = useLiveTvRecordings()
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (recordings.length === 0) {
|
||||||
|
return <EmptyMsg>No recordings yet. Schedule one from a program to get started.</EmptyMsg>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{recordings.map(r => {
|
||||||
|
const imageTag = r.ImageTags?.Primary
|
||||||
|
const img = r.Id && imageTag ? getImageUrl(serverUrl, r.Id, 'Primary', 220, imageTag) : ''
|
||||||
|
return (
|
||||||
|
<li key={r.Id}>
|
||||||
|
<button
|
||||||
|
onClick={() => r.Id && navigate(`/item/${r.Id}`)}
|
||||||
|
className="w-full text-left p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition flex items-center gap-3 focus-ring"
|
||||||
|
>
|
||||||
|
<div className="w-20 h-12 rounded-md bg-void/50 overflow-hidden shrink-0">
|
||||||
|
{img && <img src={img} alt="" className="w-full h-full object-cover" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
|
||||||
|
{r.Name || 'Untitled'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5 flex items-center gap-1.5">
|
||||||
|
<Clock size={11} />
|
||||||
|
{r.StartDate ? new Date(r.StartDate).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
||||||
|
{r.ChannelName && <span className="text-text-4"> - {r.ChannelName}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduledList() {
|
||||||
|
const { data: timers = [], isLoading } = useLiveTvTimers()
|
||||||
|
|
||||||
|
if (isLoading) return <Skeleton />
|
||||||
|
if (timers.length === 0) {
|
||||||
|
return <EmptyMsg>Nothing on the schedule.</EmptyMsg>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{timers.map((t: any) => (
|
||||||
|
<li
|
||||||
|
key={t.Id}
|
||||||
|
className="p-3 rounded-lg bg-elevated/40 ring-1 ring-border flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-md bg-accent/10 grid place-items-center shrink-0">
|
||||||
|
<CalendarEvent size={16} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] font-medium text-text-1 tracking-tight truncate">
|
||||||
|
{t.Name || t.ProgramName || 'Scheduled recording'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-text-3 truncate mt-0.5">
|
||||||
|
{t.StartDate ? new Date(t.StartDate).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
||||||
|
{t.ChannelName && <span className="text-text-4"> - {t.ChannelName}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{t.Status && (
|
||||||
|
<span className="text-[10.5px] uppercase tracking-[0.14em] font-semibold text-text-4">
|
||||||
|
{t.Status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function programTime(start?: string | null, end?: string | null): string {
|
||||||
|
if (!start) return ''
|
||||||
|
const s = new Date(start)
|
||||||
|
const e = end ? new Date(end) : null
|
||||||
|
if (Number.isNaN(s.getTime())) return ''
|
||||||
|
const fmt = (d: Date) => d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
||||||
|
return e ? `${fmt(s)}-${fmt(e)} ` : `${fmt(s)} `
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-[68px] rounded-lg bg-elevated/20 animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyMsg({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
||||||
|
<p className="text-[12.5px] text-text-3">{children}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Eye, EyeOff, Loader2, AlertCircle, Server, User as UserIcon, Lock, ArrowRight } from '../lib/icons'
|
||||||
|
import { jellyfinClient, getLastServerUrl } from '../api/jellyfin'
|
||||||
|
import type { AuthState } from '../api/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLogin: (auth: AuthState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage({ onLogin }: Props) {
|
||||||
|
const [serverUrl, setServerUrl] = useState(() => getLastServerUrl())
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!serverUrl.trim()) {
|
||||||
|
setError('Please enter a server URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
jellyfinClient.connect(serverUrl.trim())
|
||||||
|
const auth = await jellyfinClient.login(username.trim(), password)
|
||||||
|
onLogin(auth)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.message ||
|
||||||
|
'Connection failed. Check your server URL and credentials.'
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-void overflow-hidden">
|
||||||
|
{/* Cinematic ambient backdrop */}
|
||||||
|
<div className="absolute inset-0 -z-10">
|
||||||
|
<div className="absolute top-[-30%] left-[-20%] w-[80%] h-[80%] rounded-full bg-accent/10 blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-30%] right-[-20%] w-[70%] h-[70%] rounded-full bg-cool/8 blur-[120px]" />
|
||||||
|
<div className="absolute inset-0 noise" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="w-full max-w-[400px] mx-6 relative"
|
||||||
|
>
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="flex flex-col items-center mb-9">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.85, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.34, 1.56, 0.64, 1], delay: 0.1 }}
|
||||||
|
className="relative w-14 h-14 mb-4"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-accent-glow blur-xl" />
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-gradient-to-br from-accent to-accent-press shadow-lg" />
|
||||||
|
<div className="relative w-full h-full rounded-2xl grid place-items-center text-void font-bold text-2xl font-display">
|
||||||
|
j
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.25 }}
|
||||||
|
className="font-display text-2xl font-semibold text-text-1 tracking-tight"
|
||||||
|
>
|
||||||
|
Welcome back
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.32 }}
|
||||||
|
className="text-[13px] text-text-3 mt-1"
|
||||||
|
>
|
||||||
|
Connect to your Jellyfin server to begin
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<Field
|
||||||
|
label="Server URL"
|
||||||
|
icon={Server}
|
||||||
|
type="url"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={setServerUrl}
|
||||||
|
placeholder="https://jellyfin.example.com"
|
||||||
|
autoFocus={!serverUrl}
|
||||||
|
delay={0.4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Username"
|
||||||
|
icon={UserIcon}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={setUsername}
|
||||||
|
placeholder="Your username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus={!!serverUrl}
|
||||||
|
delay={0.45}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Password"
|
||||||
|
icon={Lock}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Your password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
delay={0.5}
|
||||||
|
trailing={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-text-4 hover:text-text-2 transition-colors p-1 rounded-md focus-ring"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -4, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, y: -4, height: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-error/8 border border-error/20">
|
||||||
|
<AlertCircle size={14} className="text-error/90 shrink-0 mt-px" />
|
||||||
|
<p className="text-[12.5px] text-error/95 leading-relaxed">{error}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.55 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className="group relative w-full h-11 mt-2 bg-accent hover:bg-accent-hover text-void text-[14px] font-semibold rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shadow-lg shadow-accent/20 hover:shadow-accent/40 focus-ring"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={15} className="animate-[spin-soft_0.8s_linear_infinite]" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Sign in
|
||||||
|
<ArrowRight
|
||||||
|
size={15}
|
||||||
|
className="transition-transform duration-200 group-hover:translate-x-0.5"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.7 }}
|
||||||
|
className="text-center text-[11px] text-text-4 mt-6"
|
||||||
|
>
|
||||||
|
Your server connection stays on this device.
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
autoComplete,
|
||||||
|
delay = 0,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
icon: typeof Server
|
||||||
|
type: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
autoComplete?: string
|
||||||
|
delay?: number
|
||||||
|
trailing?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<label className="block text-[11px] font-medium text-text-3 mb-1.5 uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Icon
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-4 group-focus-within:text-accent transition-colors duration-200"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
className={`w-full h-11 pl-10 ${trailing ? 'pr-10' : 'pr-3'} bg-elevated/60 hover:bg-elevated rounded-lg text-[13.5px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/50 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200`}
|
||||||
|
/>
|
||||||
|
{trailing && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">{trailing}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Music, Disc3 } from '../lib/icons'
|
||||||
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import PosterCard from '../components/ui/PosterCard'
|
||||||
|
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
|
||||||
|
|
||||||
|
export default function MusicPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const serverUrl = getStoredServerUrl()
|
||||||
|
|
||||||
|
const { data: artists, isLoading: artistsLoading } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['MusicArtist'],
|
||||||
|
sortBy: ['SortName'],
|
||||||
|
sortOrder: ['Ascending'],
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: albums, isLoading: albumsLoading } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['MusicAlbum'],
|
||||||
|
sortBy: ['SortName'],
|
||||||
|
sortOrder: ['Ascending'],
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">Music</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Albums */}
|
||||||
|
<Section title="Albums" icon={Disc3}>
|
||||||
|
{albumsLoading ? (
|
||||||
|
<SquareGridSkeleton count={14} />
|
||||||
|
) : !albums?.Items?.length ? (
|
||||||
|
<EmptyState message="No albums" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7">
|
||||||
|
{albums.Items.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.Id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3), ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<PosterCard
|
||||||
|
item={item}
|
||||||
|
aspect="square"
|
||||||
|
priority={i < 12}
|
||||||
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Artists */}
|
||||||
|
<Section title="Artists" icon={Music}>
|
||||||
|
{artistsLoading ? (
|
||||||
|
<ArtistGridSkeleton />
|
||||||
|
) : !artists?.Items?.length ? (
|
||||||
|
<EmptyState message="No artists" />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-x-3 gap-y-5">
|
||||||
|
{artists.Items.map((item, i) => {
|
||||||
|
const imageUrl = getBestImage(serverUrl, item, 'primary', 240)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={item.Id}
|
||||||
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.015, 0.3) }}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
className="flex flex-col items-center text-center gap-2 group focus-ring rounded-xl p-1"
|
||||||
|
>
|
||||||
|
<div className="relative w-20 h-20 md:w-24 md:h-24">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/0 group-hover:bg-accent/15 blur-xl transition-all duration-300" />
|
||||||
|
<div className="relative w-full h-full rounded-full overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/40 transition-all duration-300">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={item.Name || ''}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-lg font-display font-semibold bg-gradient-to-br from-elevated to-surface">
|
||||||
|
{(item.Name || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] text-text-1 truncate w-full font-medium tracking-tight">{item.Name}</span>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: typeof Music
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Icon size={14} className="text-accent" strokeWidth={2} />
|
||||||
|
<h2 className="text-[16px] font-semibold text-text-1 tracking-tight">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ message }: { message: string }) {
|
||||||
|
return <p className="text-[12px] text-text-4">{message}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SquareGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 gap-y-7">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-square rounded-lg" />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtistGridSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-x-3 gap-y-5">
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center">
|
||||||
|
<div className="skeleton w-20 h-20 md:w-24 md:h-24 rounded-full" />
|
||||||
|
<div className="skeleton h-3 w-16 mt-2 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Cake, MapPin, Skull, ExternalLink, ChevronDown } from '../lib/icons'
|
||||||
|
import { useTmdbPerson } from '../hooks/use-tmdb'
|
||||||
|
import { useWikiResolve } from '../hooks/use-external'
|
||||||
|
import { getTmdbImageUrl } from '../api/tmdb'
|
||||||
|
import type { TmdbCombinedCreditCast, TmdbCombinedCreditCrew } from '../api/tmdb'
|
||||||
|
import { formatDate } from '../lib/format'
|
||||||
|
import { usePosterGridClasses } from '../lib/density'
|
||||||
|
|
||||||
|
const DEPT_FILTERS = ['All', 'Acting', 'Directing', 'Writing', 'Production', 'Camera', 'Editing', 'Sound']
|
||||||
|
|
||||||
|
export default function PersonPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: person, isLoading } = useTmdbPerson(id)
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
|
||||||
|
// Wikipedia bio fallback - only fetched when TMDB has no biography of
|
||||||
|
// its own. The query is the person's name; first matching article wins.
|
||||||
|
const wikiEnabled = !!person && (!person.biography || person.biography.length < 60)
|
||||||
|
const { data: wiki } = useWikiResolve(person?.name, wikiEnabled)
|
||||||
|
|
||||||
|
const [bioExpanded, setBioExpanded] = useState(false)
|
||||||
|
const [filter, setFilter] = useState<string>('All')
|
||||||
|
|
||||||
|
const filmography = useMemo(() => {
|
||||||
|
const cast = (person?.combined_credits?.cast || []).map(c => ({ ...c, _kind: 'cast' as const }))
|
||||||
|
const crew = (person?.combined_credits?.crew || []).map(c => ({ ...c, _kind: 'crew' as const }))
|
||||||
|
let merged: ((TmdbCombinedCreditCast | TmdbCombinedCreditCrew) & { _kind: 'cast' | 'crew' })[] = [
|
||||||
|
...cast,
|
||||||
|
...crew,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (filter !== 'All') {
|
||||||
|
if (filter === 'Acting') {
|
||||||
|
merged = merged.filter(c => c._kind === 'cast')
|
||||||
|
} else {
|
||||||
|
merged = merged.filter(c => c._kind === 'crew' && (c as any).department === filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate by media id (a person may appear in cast + crew)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const unique: typeof merged = []
|
||||||
|
for (const c of merged) {
|
||||||
|
const key = `${c.media_type}-${c.id}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.push(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by popularity, then date
|
||||||
|
return unique.sort((a, b) => {
|
||||||
|
const pa = a.popularity ?? 0
|
||||||
|
const pb = b.popularity ?? 0
|
||||||
|
if (pb !== pa) return pb - pa
|
||||||
|
const da = (a.release_date || a.first_air_date || '0').slice(0, 4)
|
||||||
|
const db = (b.release_date || b.first_air_date || '0').slice(0, 4)
|
||||||
|
return db.localeCompare(da)
|
||||||
|
})
|
||||||
|
}, [person, filter])
|
||||||
|
|
||||||
|
if (isLoading) return <PersonSkeleton />
|
||||||
|
if (!person) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] text-center px-6">
|
||||||
|
<p className="text-text-2 text-[15px] mb-1">Person not found</p>
|
||||||
|
<p className="text-text-4 text-[12px]">No TMDB key configured, or this person isn't in TMDB.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = person.profile_path ? getTmdbImageUrl(person.profile_path, 'w500') : ''
|
||||||
|
const department = person.known_for_department || 'Acting'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-6 pb-12">
|
||||||
|
{/* Top row */}
|
||||||
|
<div className="grid md:grid-cols-[180px_1fr] gap-7 mb-9">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="relative w-full max-w-[180px] aspect-[2/3] rounded-xl overflow-hidden ring-1 ring-border bg-elevated mx-auto md:mx-0"
|
||||||
|
>
|
||||||
|
{profile ? (
|
||||||
|
<img src={profile} alt={person.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-3xl font-display">
|
||||||
|
{person.name[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.14em] text-accent font-semibold mb-1">
|
||||||
|
{department}
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-3xl md:text-4xl font-bold text-text-1 tracking-tight mb-3">
|
||||||
|
{person.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 flex-wrap text-[12.5px] text-text-3 mb-5">
|
||||||
|
{person.birthday && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Cake size={12} />
|
||||||
|
{formatDate(person.birthday)}
|
||||||
|
{!person.deathday && (
|
||||||
|
<span className="text-text-4">({yearsSince(person.birthday)})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{person.deathday && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Skull size={12} />
|
||||||
|
{formatDate(person.deathday)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{person.place_of_birth && (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MapPin size={12} />
|
||||||
|
{person.place_of_birth}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(person.biography || wiki?.extract) && (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`text-[13px] text-text-2 leading-relaxed max-w-[72ch] whitespace-pre-line ${
|
||||||
|
!bioExpanded ? 'line-clamp-6' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{person.biography || wiki?.extract}
|
||||||
|
</p>
|
||||||
|
{(person.biography || wiki?.extract || '').length > 600 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setBioExpanded(b => !b)}
|
||||||
|
className="text-[12px] text-accent hover:text-accent-hover transition-colors font-medium mt-1.5 inline-flex items-center gap-1 focus-ring rounded"
|
||||||
|
>
|
||||||
|
{bioExpanded ? 'Show less' : 'Read more'}
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className={`transition-transform duration-200 ${bioExpanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!person.biography && wiki && (
|
||||||
|
<p className="text-[10.5px] text-text-4 mt-1.5">Source: Wikipedia</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External links */}
|
||||||
|
<div className="flex items-center gap-1.5 mt-5 flex-wrap">
|
||||||
|
{person.imdb_id && (
|
||||||
|
<ExternalLinkChip href={`https://www.imdb.com/name/${person.imdb_id}/`} label="IMDb" />
|
||||||
|
)}
|
||||||
|
<ExternalLinkChip
|
||||||
|
href={`https://www.themoviedb.org/person/${person.id}`}
|
||||||
|
label="TMDB"
|
||||||
|
/>
|
||||||
|
{person.external_ids?.instagram_id && (
|
||||||
|
<ExternalLinkChip
|
||||||
|
href={`https://instagram.com/${person.external_ids.instagram_id}`}
|
||||||
|
label="Instagram"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{person.external_ids?.twitter_id && (
|
||||||
|
<ExternalLinkChip
|
||||||
|
href={`https://twitter.com/${person.external_ids.twitter_id}`}
|
||||||
|
label="Twitter"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{person.homepage && <ExternalLinkChip href={person.homepage} label="Website" />}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filmography */}
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<h3 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
|
||||||
|
Filmography
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] text-text-4 tabular-nums">
|
||||||
|
({filmography.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 p-0.5 bg-elevated/60 border border-border rounded-md overflow-x-auto hide-scrollbar">
|
||||||
|
{DEPT_FILTERS.map(d => {
|
||||||
|
const isActive = filter === d
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => setFilter(d)}
|
||||||
|
className={`relative h-7 px-2.5 rounded text-[11.5px] font-medium tracking-tight whitespace-nowrap transition-colors duration-150 focus-ring ${
|
||||||
|
isActive ? 'text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="filmo-active"
|
||||||
|
className="absolute inset-0 bg-accent rounded"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="relative">{d}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filmography.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-text-4 mt-4">No credits in this category.</p>
|
||||||
|
) : (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{filmography.map((c, i) => {
|
||||||
|
const title = c.title || c.name || 'Untitled'
|
||||||
|
const year = (c.release_date || c.first_air_date || '').slice(0, 4)
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={`${c.media_type}-${c.id}-${c.credit_id}`}
|
||||||
|
onClick={() => navigate(`/item/tmdb-${c.id}`)}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.4) }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
className="group text-left focus-ring rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-accent/30 transition-all duration-200">
|
||||||
|
{c.poster_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(c.poster_path, 'w300')}
|
||||||
|
alt={title}
|
||||||
|
loading="lazy"
|
||||||
|
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-3 text-2xl font-display">
|
||||||
|
{title[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-1.5 left-1.5">
|
||||||
|
<span className="inline-flex items-center h-[18px] px-1.5 rounded text-[9px] font-bold tracking-[0.04em] uppercase bg-black/55 backdrop-blur text-white border border-white/10">
|
||||||
|
{c.media_type === 'tv' ? 'TV' : 'Film'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 px-px">
|
||||||
|
<p className="text-[12.5px] font-medium text-text-1 truncate leading-tight tracking-tight">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-3 truncate leading-tight mt-0.5">
|
||||||
|
{(c as any).character ? (c as any).character : (c as any).job}
|
||||||
|
{year && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5 mx-1">·</span>
|
||||||
|
<span className="tabular-nums">{year}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLinkChip({ href, label }: { href: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2.5 bg-elevated/60 hover:bg-elevated border border-border hover:border-border-hover rounded-md text-[11px] text-text-2 hover:text-text-1 transition-colors focus-ring"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ExternalLink size={10} className="text-text-4" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearsSince(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const years = (Date.now() - d.getTime()) / (365.25 * 24 * 3600 * 1000)
|
||||||
|
return `${Math.floor(years)} yrs`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonSkeleton() {
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-6 pb-12">
|
||||||
|
<div className="grid md:grid-cols-[180px_1fr] gap-7 mb-9">
|
||||||
|
<div className="aspect-[2/3] skeleton rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="skeleton h-3 w-24 rounded" />
|
||||||
|
<div className="skeleton h-9 w-2/3 rounded" />
|
||||||
|
<div className="skeleton h-3 w-1/2 rounded" />
|
||||||
|
<div className="skeleton h-20 w-full rounded mt-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={gridCls}>
|
||||||
|
{Array.from({ length: 14 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||||
|
<div className="skeleton h-3 w-3/4 mt-2 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 mt-1 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { ListMusic } from '../lib/icons'
|
||||||
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import PosterCard from '../components/ui/PosterCard'
|
||||||
|
import { usePosterGridClasses } from '../lib/density'
|
||||||
|
|
||||||
|
export default function PlaylistsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
|
||||||
|
const { data, isLoading } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Playlist'],
|
||||||
|
sortBy: ['SortName'],
|
||||||
|
sortOrder: ['Ascending'],
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = data?.Items || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
<div className="mb-7">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Library</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||||
|
Playlists
|
||||||
|
</h1>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="text-[12px] text-text-4 tabular-nums">
|
||||||
|
{items.length.toLocaleString()} {items.length === 1 ? 'playlist' : 'playlists'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<SkeletonGrid />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.Id}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(i * 0.012, 0.3), ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
>
|
||||||
|
<PosterCard
|
||||||
|
item={item}
|
||||||
|
aspect="square"
|
||||||
|
priority={i < 12}
|
||||||
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center">
|
||||||
|
<div className="relative w-16 h-16 mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||||
|
<ListMusic size={22} className="text-text-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-medium text-text-1 mb-1.5">No playlists yet</p>
|
||||||
|
<p className="text-[12px] text-text-4 max-w-sm">
|
||||||
|
Create a playlist on your Jellyfin server and it'll show up here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonGrid() {
|
||||||
|
const gridCls = usePosterGridClasses()
|
||||||
|
return (
|
||||||
|
<div className={gridCls}>
|
||||||
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="skeleton aspect-square rounded-lg" />
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="skeleton h-3 w-3/4 rounded" />
|
||||||
|
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Star, Clock, Flame, Calendar, ChevronRight } from '../lib/icons'
|
||||||
|
import { useLibraryItems, useItemsByIds } from '../hooks/use-jellyfin'
|
||||||
|
import { usePersonalData } from '../stores/personal-data-store'
|
||||||
|
import { useDiary } from '../stores/diary-store'
|
||||||
|
import { genreBreakdown, watchStreak, totalHoursWatched, longestBinge } from '../lib/watch-stats'
|
||||||
|
import { jellyfinClient } from '../api/jellyfin'
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal profile page - the home for the user's own data layer:
|
||||||
|
* - Watch streak (#46)
|
||||||
|
* - Genre breakdown (#44)
|
||||||
|
* - Year-in-Review summary (#43)
|
||||||
|
* - Recent diary entries (#47, surfaced)
|
||||||
|
*/
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const auth = jellyfinClient.getAuthState()
|
||||||
|
const { data } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series', 'Episode'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 1000,
|
||||||
|
})
|
||||||
|
const items = useMemo(() => data?.Items || [], [data?.Items])
|
||||||
|
|
||||||
|
const yearItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter(it => {
|
||||||
|
const raw = it.UserData?.LastPlayedDate
|
||||||
|
if (!raw) return false
|
||||||
|
return new Date(raw).getFullYear() === CURRENT_YEAR
|
||||||
|
}),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
const streak = useMemo(() => watchStreak(items), [items])
|
||||||
|
const breakdown = useMemo(() => genreBreakdown(items).slice(0, 8), [items])
|
||||||
|
const breakdownYear = useMemo(() => genreBreakdown(yearItems).slice(0, 6), [yearItems])
|
||||||
|
const hoursAll = useMemo(() => totalHoursWatched(items), [items])
|
||||||
|
const hoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
|
||||||
|
const binge = useMemo(() => longestBinge(yearItems), [yearItems])
|
||||||
|
|
||||||
|
const personal = usePersonalData(s => s.entries)
|
||||||
|
// Top rated by personal rating - independent of played status, so an
|
||||||
|
// item rated highly that the user hasn't gotten around to opening
|
||||||
|
// still shows up. Resolve metadata via a separate `useItemsByIds`
|
||||||
|
// call rather than joining against the played-items list.
|
||||||
|
const topRatedIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.entries(personal)
|
||||||
|
.filter(([, e]) => e.rating >= 8)
|
||||||
|
.sort((a, b) => b[1].rating - a[1].rating)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([id]) => id),
|
||||||
|
[personal],
|
||||||
|
)
|
||||||
|
const { data: topRatedItems = [] } = useItemsByIds(topRatedIds)
|
||||||
|
const topRatedPersonal = useMemo(() => {
|
||||||
|
return topRatedIds
|
||||||
|
.map(id => {
|
||||||
|
const item = topRatedItems.find(it => it.Id === id) || items.find(i => i.Id === id)
|
||||||
|
const entry = personal[id]
|
||||||
|
if (!item || !entry) return null
|
||||||
|
return { id, rating: entry.rating, rewatchCount: entry.rewatchCount, item }
|
||||||
|
})
|
||||||
|
.filter((x): x is { id: string; rating: number; rewatchCount: number; item: any } => !!x)
|
||||||
|
}, [topRatedIds, topRatedItems, personal, items])
|
||||||
|
|
||||||
|
const diary = useDiary(s => s.entries)
|
||||||
|
const recentDiary = useMemo(
|
||||||
|
() =>
|
||||||
|
[...diary]
|
||||||
|
.sort((a, b) => b.watchedAt.localeCompare(a.watchedAt))
|
||||||
|
.slice(0, 8),
|
||||||
|
[diary],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pb-12 pt-6">
|
||||||
|
<header className="mb-8 flex items-end justify-between gap-6 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
||||||
|
Profile
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
|
||||||
|
{auth?.userName ? `${auth.userName}'s year` : 'Your year'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5">
|
||||||
|
Stats and notes that live only on this device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/stats')}
|
||||||
|
className="inline-flex items-center gap-1.5 h-9 px-3.5 rounded-md text-[12.5px] font-medium bg-elevated/60 text-text-2 border border-border hover:border-border-hover hover:text-text-1 transition-all duration-150 focus-ring"
|
||||||
|
>
|
||||||
|
See full stats
|
||||||
|
<ChevronRight size={13} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Stat tiles */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-3 mb-10">
|
||||||
|
<StatTile
|
||||||
|
label="Current streak"
|
||||||
|
value={String(streak.current)}
|
||||||
|
unit={streak.current === 1 ? 'day' : 'days'}
|
||||||
|
accent={streak.current >= 3}
|
||||||
|
icon={<Flame size={14} className="text-accent" />}
|
||||||
|
hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label={`Watched in ${CURRENT_YEAR}`}
|
||||||
|
value={String(yearItems.length)}
|
||||||
|
unit={yearItems.length === 1 ? 'item' : 'items'}
|
||||||
|
icon={<Calendar size={14} className="text-accent" />}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Hours this year"
|
||||||
|
value={hoursYear.toFixed(0)}
|
||||||
|
unit="hours"
|
||||||
|
icon={<Clock size={14} className="text-accent" />}
|
||||||
|
hint={`${hoursAll.toFixed(0)} all-time`}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Longest binge"
|
||||||
|
value={String(binge.count)}
|
||||||
|
unit={binge.count === 1 ? 'item' : 'items'}
|
||||||
|
icon={<Flame size={14} className="text-accent" />}
|
||||||
|
hint={binge.day || 'No binge yet'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genre breakdown */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
|
||||||
|
Genre breakdown
|
||||||
|
</h2>
|
||||||
|
{breakdown.length === 0 ? (
|
||||||
|
<p className="text-[12.5px] text-text-3">No genre data yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{breakdown.map((g, i) => (
|
||||||
|
<GenreBar key={g.genre} share={g} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{breakdownYear.length > 0 && (
|
||||||
|
<p className="mt-4 text-[12px] text-text-3 leading-relaxed">
|
||||||
|
Top this year:{' '}
|
||||||
|
{breakdownYear.map((g, i) => (
|
||||||
|
<span key={g.genre}>
|
||||||
|
<span className="text-text-2 font-medium">{g.genre}</span>
|
||||||
|
<span className="text-text-4 tabular-nums"> {(g.share * 100).toFixed(0)}%</span>
|
||||||
|
{i < breakdownYear.length - 1 && <span className="text-text-5"> · </span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Year in Review - top personal ratings */}
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
|
||||||
|
Your top picks
|
||||||
|
</h2>
|
||||||
|
{topRatedPersonal.length === 0 ? (
|
||||||
|
<p className="text-[12.5px] text-text-3">
|
||||||
|
No 8+/10 personal ratings yet. Use the rating row on a detail page to mark your favourites.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ol className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-2">
|
||||||
|
{topRatedPersonal.map((entry, i) => (
|
||||||
|
<TopPickRow key={entry.id} index={i + 1} entry={entry} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent diary */}
|
||||||
|
{recentDiary.length > 0 && (
|
||||||
|
<section className="mb-10">
|
||||||
|
<h2 className="text-[14px] font-semibold text-text-1 mb-3 tracking-tight">
|
||||||
|
Recent diary
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{recentDiary.map(d => (
|
||||||
|
<li
|
||||||
|
key={d.id}
|
||||||
|
className="rounded-lg bg-elevated/40 ring-1 ring-border p-3 flex items-start gap-3"
|
||||||
|
>
|
||||||
|
{d.emoji && <span className="text-[20px] leading-none">{d.emoji}</span>}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-[11.5px] text-text-3 mb-1 tracking-tight">
|
||||||
|
<span className="text-text-2 font-medium tabular-nums">
|
||||||
|
{new Date(d.watchedAt).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<NavLink itemId={d.itemId} name={d.itemName} />
|
||||||
|
{d.rating != null && d.rating > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-accent tabular-nums">
|
||||||
|
<Star size={10} fill="currentColor" stroke={0} />
|
||||||
|
{d.rating}/10
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{d.note && (
|
||||||
|
<p className="text-[12.5px] text-text-1 leading-relaxed line-clamp-3">
|
||||||
|
{d.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
hint,
|
||||||
|
icon,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit?: string
|
||||||
|
hint?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
accent?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className={`rounded-xl p-4 ring-1 ${
|
||||||
|
accent ? 'bg-accent/10 ring-accent/30' : 'bg-elevated/40 ring-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="font-display text-[28px] font-bold text-text-1 tabular-nums leading-none">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit && <span className="text-[12px] text-text-3 tracking-tight">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
{hint && <p className="text-[11px] text-text-4 mt-1.5 tracking-tight">{hint}</p>}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenreBar({ share, index }: { share: { genre: string; count: number; share: number }; index: number }) {
|
||||||
|
const pct = Math.max(2, Math.round(share.share * 100))
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -6 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(index * 0.04, 0.4) }}
|
||||||
|
className="flex items-center gap-3 text-[12px]"
|
||||||
|
>
|
||||||
|
<span className="w-32 shrink-0 text-text-2 font-medium tracking-tight truncate">
|
||||||
|
{share.genre}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-2.5 rounded-full bg-elevated/60 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="h-full bg-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 shrink-0 text-right text-text-3 tabular-nums">
|
||||||
|
{(share.share * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopPickRow({
|
||||||
|
index,
|
||||||
|
entry,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
entry: { id: string; rating: number; rewatchCount: number; item: any }
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/item/${entry.id}`)}
|
||||||
|
className="w-full flex items-center gap-3 p-2.5 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition text-left focus-ring"
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 grid place-items-center rounded-full bg-accent/15 text-accent text-[11px] font-bold tabular-nums shrink-0">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13px] text-text-1 font-medium truncate tracking-tight">
|
||||||
|
{entry.item.Name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-3 truncate tabular-nums">
|
||||||
|
{entry.item.ProductionYear}
|
||||||
|
{entry.rewatchCount > 0 && ` · ${entry.rewatchCount} rewatch${entry.rewatchCount === 1 ? '' : 'es'}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-accent tabular-nums shrink-0">
|
||||||
|
<Star size={11} fill="currentColor" stroke={0} />
|
||||||
|
{entry.rating}/10
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ itemId, name }: { itemId: string; name: string }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/item/${itemId}`)}
|
||||||
|
className="text-text-2 hover:text-accent transition focus-ring rounded font-medium truncate max-w-[14rem]"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Database, RefreshCw, Trash2, Clock, Check, AlertCircle, Settings } from '../lib/icons'
|
||||||
|
import { useArrInstances } from '../stores/arr-instances-store'
|
||||||
|
import { useRadarrLibrary, useSonarrLibrary, useRadarrQueue, useSonarrQueue } from '../hooks/use-arr'
|
||||||
|
import { radarrClient, type RadarrMovie, type RadarrQueueItem } from '../api/radarr'
|
||||||
|
import { sonarrClient, type SonarrSeries, type SonarrQueueItem } from '../api/sonarr'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated requests view: every movie + show currently sitting in
|
||||||
|
* Sonarr/Radarr but not fully available yet, with per-item actions.
|
||||||
|
*
|
||||||
|
* Data sources:
|
||||||
|
* - Radarr/Sonarr libraries (filter to monitored + missing/partial)
|
||||||
|
* - Their queues (in-flight downloads with progress)
|
||||||
|
*
|
||||||
|
* Hides itself with a setup prompt when no *arr instance is configured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Tier = 'default' | '4k'
|
||||||
|
|
||||||
|
interface AggregatedRequest {
|
||||||
|
key: string
|
||||||
|
kind: 'movie' | 'tv'
|
||||||
|
tier: Tier
|
||||||
|
arrId: number
|
||||||
|
title: string
|
||||||
|
year?: number | null
|
||||||
|
poster?: string | null
|
||||||
|
/** Render label like "Downloading 64%" or "Pending release". */
|
||||||
|
state: 'processing' | 'pending' | 'partial' | 'requested'
|
||||||
|
detail?: string
|
||||||
|
/** Optional progress 0..100 when processing. */
|
||||||
|
progress?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RequestsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const radarr = useArrInstances(s => s.pick('radarr', 'default'))
|
||||||
|
const radarr4k = useArrInstances(s => s.pick('radarr', '4k'))
|
||||||
|
const sonarr = useArrInstances(s => s.pick('sonarr', 'default'))
|
||||||
|
const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k'))
|
||||||
|
|
||||||
|
const radarrA = useRadarrLibrary('default')
|
||||||
|
const radarrB = useRadarrLibrary('4k')
|
||||||
|
const sonarrA = useSonarrLibrary('default')
|
||||||
|
const sonarrB = useSonarrLibrary('4k')
|
||||||
|
const radarrQA = useRadarrQueue('default')
|
||||||
|
const radarrQB = useRadarrQueue('4k')
|
||||||
|
const sonarrQA = useSonarrQueue('default')
|
||||||
|
const sonarrQB = useSonarrQueue('4k')
|
||||||
|
|
||||||
|
const aggregated: AggregatedRequest[] = useMemo(() => {
|
||||||
|
const out: AggregatedRequest[] = []
|
||||||
|
function pushMovies(list: RadarrMovie[] | null | undefined, queue: { records?: RadarrQueueItem[] } | null | undefined, tier: Tier) {
|
||||||
|
if (!list) return
|
||||||
|
const queueByMovieId = new Map<number, RadarrQueueItem>()
|
||||||
|
for (const q of queue?.records || []) if (q.movieId != null) queueByMovieId.set(q.movieId, q)
|
||||||
|
for (const m of list) {
|
||||||
|
if (m.hasFile) continue
|
||||||
|
if (!m.id) continue
|
||||||
|
const q = queueByMovieId.get(m.id)
|
||||||
|
const poster = m.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
|
||||||
|
if (q) {
|
||||||
|
const total = (q.size ?? 0)
|
||||||
|
const left = (q.sizeleft ?? 0)
|
||||||
|
const progress = total > 0 ? Math.round(((total - left) / total) * 100) : undefined
|
||||||
|
out.push({
|
||||||
|
key: `radarr-${tier}-${m.id}`,
|
||||||
|
kind: 'movie',
|
||||||
|
tier,
|
||||||
|
arrId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
year: m.year,
|
||||||
|
poster,
|
||||||
|
state: 'processing',
|
||||||
|
detail: q.timeleft ? `${progress ?? 0}% · ${q.timeleft} left` : `${progress ?? 0}%`,
|
||||||
|
progress,
|
||||||
|
})
|
||||||
|
} else if (m.monitored) {
|
||||||
|
out.push({
|
||||||
|
key: `radarr-${tier}-${m.id}`,
|
||||||
|
kind: 'movie',
|
||||||
|
tier,
|
||||||
|
arrId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
year: m.year,
|
||||||
|
poster,
|
||||||
|
state: 'pending',
|
||||||
|
detail: m.status === 'announced' ? 'Announced' : m.status === 'inCinemas' ? 'In cinemas' : 'Searching for release',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
key: `radarr-${tier}-${m.id}`,
|
||||||
|
kind: 'movie',
|
||||||
|
tier,
|
||||||
|
arrId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
year: m.year,
|
||||||
|
poster,
|
||||||
|
state: 'requested',
|
||||||
|
detail: 'Not monitored',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pushSeries(list: SonarrSeries[] | null | undefined, queue: { records?: SonarrQueueItem[] } | null | undefined, tier: Tier) {
|
||||||
|
if (!list) return
|
||||||
|
const queueBySeriesId = new Map<number, SonarrQueueItem[]>()
|
||||||
|
for (const q of queue?.records || []) {
|
||||||
|
if (q.seriesId == null) continue
|
||||||
|
const existing = queueBySeriesId.get(q.seriesId) || []
|
||||||
|
queueBySeriesId.set(q.seriesId, [...existing, q])
|
||||||
|
}
|
||||||
|
for (const s of list) {
|
||||||
|
if (!s.id) continue
|
||||||
|
const seasons = s.seasons || []
|
||||||
|
const totalEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeCount || 0), 0)
|
||||||
|
const haveEps = seasons.reduce((acc, x) => acc + (x.statistics?.episodeFileCount || 0), 0)
|
||||||
|
if (totalEps > 0 && haveEps >= totalEps) continue
|
||||||
|
const poster = s.images?.find(x => x.coverType === 'poster')?.remoteUrl || null
|
||||||
|
const q = queueBySeriesId.get(s.id)
|
||||||
|
const monitoredSeasons = seasons.filter(x => x.monitored).length
|
||||||
|
if (q && q.length > 0) {
|
||||||
|
out.push({
|
||||||
|
key: `sonarr-${tier}-${s.id}`,
|
||||||
|
kind: 'tv',
|
||||||
|
tier,
|
||||||
|
arrId: s.id,
|
||||||
|
title: s.title,
|
||||||
|
year: s.year,
|
||||||
|
poster,
|
||||||
|
state: 'processing',
|
||||||
|
detail: `${q.length} episode${q.length === 1 ? '' : 's'} downloading`,
|
||||||
|
})
|
||||||
|
} else if (haveEps > 0 && haveEps < totalEps) {
|
||||||
|
out.push({
|
||||||
|
key: `sonarr-${tier}-${s.id}`,
|
||||||
|
kind: 'tv',
|
||||||
|
tier,
|
||||||
|
arrId: s.id,
|
||||||
|
title: s.title,
|
||||||
|
year: s.year,
|
||||||
|
poster,
|
||||||
|
state: 'partial',
|
||||||
|
detail: `${haveEps} of ${totalEps} episodes`,
|
||||||
|
progress: totalEps > 0 ? Math.round((haveEps / totalEps) * 100) : undefined,
|
||||||
|
})
|
||||||
|
} else if (monitoredSeasons > 0) {
|
||||||
|
out.push({
|
||||||
|
key: `sonarr-${tier}-${s.id}`,
|
||||||
|
kind: 'tv',
|
||||||
|
tier,
|
||||||
|
arrId: s.id,
|
||||||
|
title: s.title,
|
||||||
|
year: s.year,
|
||||||
|
poster,
|
||||||
|
state: 'pending',
|
||||||
|
detail: `${monitoredSeasons} season${monitoredSeasons === 1 ? '' : 's'} monitored`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
key: `sonarr-${tier}-${s.id}`,
|
||||||
|
kind: 'tv',
|
||||||
|
tier,
|
||||||
|
arrId: s.id,
|
||||||
|
title: s.title,
|
||||||
|
year: s.year,
|
||||||
|
poster,
|
||||||
|
state: 'requested',
|
||||||
|
detail: 'No seasons monitored',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushMovies(radarrA.data, radarrQA.data, 'default')
|
||||||
|
pushMovies(radarrB.data, radarrQB.data, '4k')
|
||||||
|
pushSeries(sonarrA.data, sonarrQA.data, 'default')
|
||||||
|
pushSeries(sonarrB.data, sonarrQB.data, '4k')
|
||||||
|
|
||||||
|
// Sort: processing first, then partial, pending, requested. Within
|
||||||
|
// each bucket, alphabetical.
|
||||||
|
const order: Record<AggregatedRequest['state'], number> = { processing: 0, partial: 1, pending: 2, requested: 3 }
|
||||||
|
return out.sort((a, b) => {
|
||||||
|
const oa = order[a.state]
|
||||||
|
const ob = order[b.state]
|
||||||
|
if (oa !== ob) return oa - ob
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
})
|
||||||
|
}, [radarrA.data, radarrB.data, sonarrA.data, sonarrB.data, radarrQA.data, radarrQB.data, sonarrQA.data, sonarrQB.data])
|
||||||
|
|
||||||
|
const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const out = { processing: 0, partial: 0, pending: 0, requested: 0 }
|
||||||
|
for (const r of aggregated) out[r.state]++
|
||||||
|
return out
|
||||||
|
}, [aggregated])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-12">
|
||||||
|
<header className="mb-7 flex flex-col md:flex-row md:items-end md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Requests</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||||
|
Pending downloads
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5">
|
||||||
|
Items currently in your *arr stack that haven't finished landing in Jellyfin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!noInstance && aggregated.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{counts.processing > 0 && <CountChip tone="blue" label="Downloading" value={counts.processing} />}
|
||||||
|
{counts.partial > 0 && <CountChip tone="amber" label="Partial" value={counts.partial} />}
|
||||||
|
{counts.pending > 0 && <CountChip tone="purple" label="Pending" value={counts.pending} />}
|
||||||
|
{counts.requested > 0 && <CountChip tone="neutral" label="Requested" value={counts.requested} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{noInstance ? (
|
||||||
|
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
|
||||||
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-accent/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||||
|
<Database size={20} className="text-accent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1.5">No Sonarr or Radarr connected</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-md mx-auto leading-relaxed mb-5">
|
||||||
|
Connect a Sonarr or Radarr instance to track active downloads, monitor pending releases, and manage requests here.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings#sonarr-radarr')}
|
||||||
|
className="inline-flex items-center gap-1.5 h-10 px-5 rounded-full bg-accent hover:bg-accent-hover text-void text-[12.5px] font-semibold tracking-tight transition shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] focus-ring"
|
||||||
|
>
|
||||||
|
<Settings size={12} stroke={2} />
|
||||||
|
Open Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : aggregated.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-elevated/30 ring-1 ring-border p-10 text-center">
|
||||||
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||||
|
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
|
||||||
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
|
||||||
|
<Check size={18} className="text-success" stroke={2.5} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">All caught up</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
||||||
|
Every monitored item in your *arr stack is fully downloaded.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{aggregated.map((req, i) => (
|
||||||
|
<RequestRow key={req.key} req={req} index={i} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestRow({ req, index }: { req: AggregatedRequest; index: number }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const radarrInstance = useArrInstances(s => s.pick('radarr', req.tier))
|
||||||
|
const sonarrInstance = useArrInstances(s => s.pick('sonarr', req.tier))
|
||||||
|
|
||||||
|
async function searchNow() {
|
||||||
|
if (req.kind === 'movie' && radarrInstance) {
|
||||||
|
await radarrClient(radarrInstance).searchMovie(req.arrId)
|
||||||
|
qc.invalidateQueries({ queryKey: ['radarr', 'queue'] })
|
||||||
|
} else if (req.kind === 'tv' && sonarrInstance) {
|
||||||
|
await sonarrClient(sonarrInstance).searchSeries(req.arrId)
|
||||||
|
qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
const ok = confirm(`Remove "${req.title}" from ${req.kind === 'movie' ? 'Radarr' : 'Sonarr'}? Files on disk will be left in place.`)
|
||||||
|
if (!ok) return
|
||||||
|
if (req.kind === 'movie' && radarrInstance) {
|
||||||
|
await radarrClient(radarrInstance).removeMovie(req.arrId, false)
|
||||||
|
qc.invalidateQueries({ queryKey: ['radarr', 'library'] })
|
||||||
|
} else if (req.kind === 'tv' && sonarrInstance) {
|
||||||
|
await sonarrClient(sonarrInstance).removeSeries(req.arrId, false)
|
||||||
|
qc.invalidateQueries({ queryKey: ['sonarr', 'library'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, delay: Math.min(index * 0.02, 0.3) }}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong transition"
|
||||||
|
>
|
||||||
|
<div className="shrink-0 w-12 aspect-[2/3] rounded bg-black overflow-hidden ring-1 ring-border">
|
||||||
|
{req.poster && (
|
||||||
|
<img src={req.poster} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||||
|
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{req.title}</p>
|
||||||
|
{req.year && <span className="text-[11px] text-text-4 tabular-nums">{req.year}</span>}
|
||||||
|
<StateChip state={req.state} />
|
||||||
|
{req.tier === '4k' && (
|
||||||
|
<span className="inline-flex items-center h-[16px] px-1.5 rounded text-[9px] font-bold uppercase tracking-[0.06em] bg-cool/20 text-cool ring-1 ring-cool/30">
|
||||||
|
4K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{req.detail && (
|
||||||
|
<p className="text-[11.5px] text-text-3">{req.detail}</p>
|
||||||
|
)}
|
||||||
|
{req.progress != null && req.progress >= 0 && req.progress <= 100 && (
|
||||||
|
<div className="mt-1.5 h-1 rounded-full bg-elevated overflow-hidden max-w-md">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-[width] duration-300"
|
||||||
|
style={{ width: `${req.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={searchNow}
|
||||||
|
title="Search now"
|
||||||
|
aria-label="Search now"
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-accent hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} stroke={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancel}
|
||||||
|
title="Remove from *arr"
|
||||||
|
aria-label="Remove"
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full text-text-3 hover:text-red-300 hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountChip({ tone, label, value }: { tone: 'blue' | 'amber' | 'purple' | 'neutral'; label: string; value: number }) {
|
||||||
|
const palette =
|
||||||
|
tone === 'blue' ? 'bg-blue-500/12 text-blue-200 ring-blue-400/30'
|
||||||
|
: tone === 'amber' ? 'bg-amber-500/12 text-amber-200 ring-amber-400/30'
|
||||||
|
: tone === 'purple' ? 'bg-purple-500/12 text-purple-200 ring-purple-400/30'
|
||||||
|
: 'bg-elevated/60 text-text-2 ring-border'
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full ring-1 text-[11.5px] tracking-tight ${palette}`}>
|
||||||
|
<span className="font-semibold tabular-nums">{value}</span>
|
||||||
|
<span className="opacity-80">{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StateChip({ state }: { state: AggregatedRequest['state'] }) {
|
||||||
|
const Icon =
|
||||||
|
state === 'processing' ? RefreshCw
|
||||||
|
: state === 'partial' ? Check
|
||||||
|
: state === 'pending' ? Clock
|
||||||
|
: AlertCircle
|
||||||
|
const tone =
|
||||||
|
state === 'processing' ? 'bg-blue-500/20 text-blue-200 ring-blue-400/30'
|
||||||
|
: state === 'partial' ? 'bg-amber-500/20 text-amber-200 ring-amber-400/30'
|
||||||
|
: state === 'pending' ? 'bg-purple-500/20 text-purple-200 ring-purple-400/30'
|
||||||
|
: 'bg-elevated/80 text-text-2 ring-border'
|
||||||
|
const label =
|
||||||
|
state === 'processing' ? 'Downloading'
|
||||||
|
: state === 'partial' ? 'Partial'
|
||||||
|
: state === 'pending' ? 'Pending'
|
||||||
|
: 'Requested'
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 h-[16px] px-1.5 rounded text-[9.5px] font-bold uppercase tracking-[0.06em] ring-1 ${tone}`}>
|
||||||
|
<Icon size={9} stroke={2.5} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Search, X, Loader2 } from '../lib/icons'
|
||||||
|
import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
|
||||||
|
import { useFuzzyLibrarySearch } from '../hooks/use-fuzzy-search'
|
||||||
|
import { useTmdbSearch } from '../hooks/use-tmdb'
|
||||||
|
import { mapTmdbToJf } from '../lib/tmdb-mapping'
|
||||||
|
import type { BaseItemDto } from '../api/types'
|
||||||
|
import { PeopleSection, MoviesShowsSection, EpisodesSection, MusicSection } from './search/sections'
|
||||||
|
import { EmptyState, LoadingState, NoResults } from './search/empty-states'
|
||||||
|
|
||||||
|
const RECENT_KEY = 'jf_recent_searches'
|
||||||
|
const RECENT_MAX = 8
|
||||||
|
|
||||||
|
function readRecent(): string[] {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENT_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const arr = JSON.parse(raw)
|
||||||
|
return Array.isArray(arr) ? arr.filter(s => typeof s === 'string').slice(0, RECENT_MAX) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushRecent(q: string) {
|
||||||
|
const trimmed = q.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
const cur = readRecent()
|
||||||
|
const next = [trimmed, ...cur.filter(s => s.toLowerCase() !== trimmed.toLowerCase())].slice(0, RECENT_MAX)
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecent() {
|
||||||
|
try { localStorage.removeItem(RECENT_KEY) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [debounced, setDebounced] = useState('')
|
||||||
|
const [recents, setRecents] = useState<string[]>(() => readRecent())
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const fuzzy = useFuzzyLibrarySearch(query)
|
||||||
|
const tmdb = useTmdbSearch(debounced)
|
||||||
|
const libraryByTmdb = useLibraryByTmdbId()
|
||||||
|
const jfLoading = fuzzy.isLoading
|
||||||
|
const jfFetching = fuzzy.isFetching
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(query), 220)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Persist successful searches once results land. Avoids polluting the
|
||||||
|
// recents list with mid-typing fragments by waiting for a stable query.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debounced) return
|
||||||
|
if (debounced.length < 2) return
|
||||||
|
if (jfLoading || jfFetching) return
|
||||||
|
pushRecent(debounced)
|
||||||
|
setRecents(readRecent())
|
||||||
|
}, [debounced, jfLoading, jfFetching])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (query) setQuery('')
|
||||||
|
else navigate(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [navigate, query])
|
||||||
|
|
||||||
|
const isSearching = !!query && (jfLoading || tmdb.isLoading) && !fuzzy.catalogReady
|
||||||
|
const hasQuery = !!query.trim()
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const out: Record<string, BaseItemDto[]> = {}
|
||||||
|
for (const it of fuzzy.catalogResults) {
|
||||||
|
const t = (it.Type || 'Other') as string
|
||||||
|
if (!out[t]) out[t] = []
|
||||||
|
out[t].push(it)
|
||||||
|
}
|
||||||
|
for (const it of fuzzy.episodesAndTracks) {
|
||||||
|
const t = (it.Type || 'Other') as string
|
||||||
|
if (!out[t]) out[t] = []
|
||||||
|
out[t].push(it)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [fuzzy.catalogResults, fuzzy.episodesAndTracks])
|
||||||
|
|
||||||
|
const tmdbResults = tmdb.data?.results || []
|
||||||
|
const tmdbPeople = tmdbResults.filter(r => r.media_type === 'person').slice(0, 8)
|
||||||
|
const tmdbMovies = tmdbResults.filter(r => r.media_type === 'movie')
|
||||||
|
const tmdbTv = tmdbResults.filter(r => r.media_type === 'tv')
|
||||||
|
|
||||||
|
const tmdbMappedMovies = useMemo(
|
||||||
|
() => mapTmdbToJf(tmdbMovies, libraryByTmdb.data),
|
||||||
|
[tmdbMovies, libraryByTmdb.data],
|
||||||
|
)
|
||||||
|
const tmdbMappedTv = useMemo(
|
||||||
|
() => mapTmdbToJf(tmdbTv, libraryByTmdb.data),
|
||||||
|
[tmdbTv, libraryByTmdb.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local items first (full BaseItemDto with ImageTags + ProviderIds).
|
||||||
|
// TMDB extras filtered against local TMDB ids so library hits don't
|
||||||
|
// duplicate. Anything in the library overrides its TMDB twin.
|
||||||
|
const localMovies = (grouped.Movie || []) as BaseItemDto[]
|
||||||
|
const localSeries = (grouped.Series || []) as BaseItemDto[]
|
||||||
|
const localTmdbIds = new Set<string>()
|
||||||
|
for (const it of [...localMovies, ...localSeries]) {
|
||||||
|
const t = (it as any).ProviderIds?.Tmdb
|
||||||
|
if (t) localTmdbIds.add(String(t))
|
||||||
|
}
|
||||||
|
const tmdbExtras = [...tmdbMappedMovies, ...tmdbMappedTv].filter(it => {
|
||||||
|
const t = (it as any).ProviderIds?.Tmdb
|
||||||
|
return !!t && !localTmdbIds.has(String(t))
|
||||||
|
})
|
||||||
|
|
||||||
|
const localCards: BaseItemDto[] = [...localMovies, ...localSeries].map(
|
||||||
|
it => ({ ...(it as any), _inLibrary: true } as BaseItemDto),
|
||||||
|
)
|
||||||
|
const mixedCards: BaseItemDto[] = [...localCards, ...tmdbExtras]
|
||||||
|
|
||||||
|
const episodes = (grouped.Episode || []) as BaseItemDto[]
|
||||||
|
const albums = (grouped.MusicAlbum || []) as BaseItemDto[]
|
||||||
|
const artists = (grouped.MusicArtist || []) as BaseItemDto[]
|
||||||
|
const tracks = (grouped.Audio || []) as BaseItemDto[]
|
||||||
|
|
||||||
|
const totalLocal =
|
||||||
|
localMovies.length + localSeries.length + episodes.length + albums.length + artists.length + tracks.length
|
||||||
|
const noResults =
|
||||||
|
hasQuery &&
|
||||||
|
!isSearching &&
|
||||||
|
totalLocal === 0 &&
|
||||||
|
tmdbPeople.length === 0 &&
|
||||||
|
tmdbExtras.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full" style={{ isolation: 'isolate' }}>
|
||||||
|
{/* Sticky search input - fully opaque + isolation: isolate + a high
|
||||||
|
z so cards (which may have transform/perspective stacking) cannot
|
||||||
|
render on top during scroll. */}
|
||||||
|
<div
|
||||||
|
className="sticky top-0 border-b border-border px-7 pt-5 pb-4"
|
||||||
|
style={{ zIndex: 50, backgroundColor: 'var(--color-void)' }}
|
||||||
|
>
|
||||||
|
<div className="relative max-w-2xl mx-auto">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-3" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
placeholder="Search movies, shows, episodes, people, music..."
|
||||||
|
className="w-full h-12 pl-12 pr-12 bg-elevated/60 hover:bg-elevated rounded-xl text-[15px] text-text-1 placeholder:text-text-4 border border-border focus:outline-none focus:border-accent/60 focus:ring-2 focus:ring-accent/20 focus:bg-elevated transition-all duration-200"
|
||||||
|
aria-label="Search"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 size={14} className="text-text-3 animate-[spin-soft_1s_linear_infinite]" />
|
||||||
|
)}
|
||||||
|
{query && !isSearching && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setQuery(''); inputRef.current?.focus() }}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors focus-ring"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<kbd className="hidden sm:inline-flex items-center px-1.5 h-5 rounded text-[10px] font-mono text-text-4 bg-void/60 border border-border">
|
||||||
|
esc
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-7 py-8 max-w-[1600px] mx-auto">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!hasQuery && (
|
||||||
|
<EmptyState
|
||||||
|
key="empty"
|
||||||
|
recents={recents}
|
||||||
|
onPick={s => { setQuery(s); inputRef.current?.focus() }}
|
||||||
|
onClearRecents={() => { clearRecent(); setRecents([]) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasQuery && isSearching && <LoadingState key="loading" />}
|
||||||
|
|
||||||
|
{hasQuery && noResults && <NoResults key="noresults" query={query} />}
|
||||||
|
|
||||||
|
{hasQuery && !isSearching && !noResults && (
|
||||||
|
<motion.div
|
||||||
|
key="results"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="space-y-12"
|
||||||
|
>
|
||||||
|
{tmdbPeople.length > 0 && <PeopleSection people={tmdbPeople} />}
|
||||||
|
|
||||||
|
{mixedCards.length > 0 && (
|
||||||
|
<MoviesShowsSection
|
||||||
|
cards={mixedCards}
|
||||||
|
localCount={localCards.length}
|
||||||
|
tmdbExtraCount={tmdbExtras.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{episodes.length > 0 && <EpisodesSection items={episodes} />}
|
||||||
|
|
||||||
|
{(albums.length > 0 || artists.length > 0 || tracks.length > 0) && (
|
||||||
|
<MusicSection albums={albums} artists={artists} tracks={tracks} />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useMemo, useState, type ComponentType } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Play as PlayIcon,
|
||||||
|
Key,
|
||||||
|
Info,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Palette,
|
||||||
|
Volume2,
|
||||||
|
Database,
|
||||||
|
Home,
|
||||||
|
ListDetails,
|
||||||
|
Tv,
|
||||||
|
User,
|
||||||
|
Search,
|
||||||
|
Hash,
|
||||||
|
Activity,
|
||||||
|
X,
|
||||||
|
} from '../lib/icons'
|
||||||
|
import { getStoredAuth } from '../api/jellyfin'
|
||||||
|
import { usePreferencesStore } from '../stores/preferences-store'
|
||||||
|
import { SettingsSearchContext, type SettingsSearchValue } from './settings/_ui'
|
||||||
|
import { ServerSection } from './settings/sections/Server'
|
||||||
|
import { ServersSection } from './settings/sections/Servers'
|
||||||
|
import { ServerDashboardSection } from './settings/sections/ServerDashboard'
|
||||||
|
import { PlaybackSection } from './settings/sections/Playback'
|
||||||
|
import { AudioSection } from './settings/sections/Audio'
|
||||||
|
import { DisplaySection } from './settings/sections/Display'
|
||||||
|
import { HomePageSection } from './settings/sections/Home'
|
||||||
|
import { DetailPageSection } from './settings/sections/Detail'
|
||||||
|
import { EpisodesSection } from './settings/sections/Episodes'
|
||||||
|
import { DiscoverySection } from './settings/sections/Discovery'
|
||||||
|
import { TmdbSection } from './settings/sections/Tmdb'
|
||||||
|
import { FanartSection } from './settings/sections/Fanart'
|
||||||
|
import { TraktSection } from './settings/sections/Trakt'
|
||||||
|
import { ArrSection } from './settings/sections/Arr'
|
||||||
|
import { PersonalSettingsSection } from './settings/sections/Personal'
|
||||||
|
import { PrivacySection } from './settings/sections/Privacy'
|
||||||
|
import { AboutSection } from './settings/sections/About'
|
||||||
|
import { ShortcutsSection } from './settings/sections/Shortcuts'
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────── */
|
||||||
|
/* Categories - drive the left-rail nav and section anchors */
|
||||||
|
/* ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
id: string
|
||||||
|
icon: ComponentType<{ size?: number; stroke?: number; className?: string }>
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
label: string
|
||||||
|
items: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_GROUPS: CategoryGroup[] = [
|
||||||
|
{
|
||||||
|
label: 'Account',
|
||||||
|
items: [
|
||||||
|
{ id: 'server', icon: Server, label: 'Server' },
|
||||||
|
{ id: 'servers', icon: Server, label: 'All servers' },
|
||||||
|
{ id: 'server-dashboard', icon: Activity, label: 'Dashboard' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Playback',
|
||||||
|
items: [
|
||||||
|
{ id: 'playback', icon: PlayIcon, label: 'Playback' },
|
||||||
|
{ id: 'audio', icon: Volume2, label: 'Audio & Subtitles' },
|
||||||
|
{ id: 'shortcuts', icon: Hash, label: 'Shortcuts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Appearance',
|
||||||
|
items: [
|
||||||
|
{ id: 'display', icon: Palette, label: 'Display' },
|
||||||
|
{ id: 'home-page', icon: Home, label: 'Home page' },
|
||||||
|
{ id: 'detail-page', icon: ListDetails, label: 'Detail page' },
|
||||||
|
{ id: 'episodes', icon: Tv, label: 'Episodes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Discovery',
|
||||||
|
items: [
|
||||||
|
{ id: 'discovery', icon: Globe, label: 'Region & content' },
|
||||||
|
{ id: 'tmdb', icon: Key, label: 'TMDB' },
|
||||||
|
{ id: 'fanart', icon: Palette, label: 'Fanart.tv' },
|
||||||
|
{ id: 'trakt', icon: Activity, label: 'Trakt.tv' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Requests',
|
||||||
|
items: [
|
||||||
|
{ id: 'sonarr-radarr', icon: Database, label: 'Sonarr & Radarr' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data',
|
||||||
|
items: [
|
||||||
|
{ id: 'personal', icon: User, label: 'Your data' },
|
||||||
|
{ id: 'privacy', icon: Lock, label: 'Privacy' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'About',
|
||||||
|
items: [
|
||||||
|
{ id: 'about', icon: Info, label: 'About' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORIES: Category[] = CATEGORY_GROUPS.flatMap(g => g.items)
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const prefs = usePreferencesStore()
|
||||||
|
const auth = getStoredAuth()
|
||||||
|
const serverHost = (() => {
|
||||||
|
try { return auth?.serverUrl ? new URL(auth.serverUrl).host : '-' } catch { return '-' }
|
||||||
|
})()
|
||||||
|
|
||||||
|
const [active, setActive] = useState<string>('server')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const trimmed = query.trim().toLowerCase()
|
||||||
|
const search = useMemo<SettingsSearchValue>(
|
||||||
|
() => ({
|
||||||
|
query: trimmed,
|
||||||
|
matches: (haystack: string) => {
|
||||||
|
if (!trimmed) return true
|
||||||
|
return haystack.toLowerCase().includes(trimmed)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[trimmed],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Track the section closest to the top of the scroll viewport so the
|
||||||
|
// nav highlights what the user is currently reading.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.querySelector('main.content-scroll') as HTMLElement | null
|
||||||
|
if (!root) return
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
entries => {
|
||||||
|
const visible = entries
|
||||||
|
.filter(e => e.isIntersecting)
|
||||||
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||||
|
if (visible.length > 0) setActive(visible[0].target.id)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
rootMargin: '-15% 0px -65% 0px',
|
||||||
|
threshold: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CATEGORIES.forEach(c => {
|
||||||
|
const el = document.getElementById(c.id)
|
||||||
|
if (el) obs.observe(el)
|
||||||
|
})
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function scrollTo(id: string) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pt-4 pb-16">
|
||||||
|
<div className="mb-9 max-w-3xl">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||||
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Preferences</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">Settings</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5">Customize your Jellyfin experience</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsSearchContext.Provider value={search}>
|
||||||
|
<div className="flex gap-10 items-start">
|
||||||
|
<SectionNav
|
||||||
|
active={active}
|
||||||
|
onSelect={scrollTo}
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 max-w-2xl space-y-12">
|
||||||
|
<ServerSection serverHost={serverHost} userName={auth?.userName} />
|
||||||
|
<ServersSection />
|
||||||
|
<ServerDashboardSection />
|
||||||
|
<PlaybackSection prefs={prefs} />
|
||||||
|
<AudioSection prefs={prefs} />
|
||||||
|
<ShortcutsSection />
|
||||||
|
<DisplaySection prefs={prefs} />
|
||||||
|
<HomePageSection prefs={prefs} />
|
||||||
|
<DetailPageSection prefs={prefs} />
|
||||||
|
<EpisodesSection prefs={prefs} />
|
||||||
|
<DiscoverySection prefs={prefs} />
|
||||||
|
<TmdbSection prefs={prefs} />
|
||||||
|
<FanartSection prefs={prefs} />
|
||||||
|
<TraktSection />
|
||||||
|
<ArrSection />
|
||||||
|
<PersonalSettingsSection />
|
||||||
|
<PrivacySection />
|
||||||
|
<AboutSection />
|
||||||
|
<SettingsEmptyState query={trimmed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSearchContext.Provider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No results" message shown when the current search query hides every
|
||||||
|
* row on the page. Uses an effect to count visible rows via DOM rather
|
||||||
|
* than threading state through every row.
|
||||||
|
*/
|
||||||
|
function SettingsEmptyState({ query }: { query: string }) {
|
||||||
|
const [empty, setEmpty] = useState(false)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!query) {
|
||||||
|
setEmpty(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const visible = document.querySelectorAll('[data-settings-row]').length
|
||||||
|
setEmpty(visible === 0)
|
||||||
|
}, [query])
|
||||||
|
if (!query || !empty) return null
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
||||||
|
<p className="text-[13px] text-text-2 font-medium mb-1.5">No settings match "{query}"</p>
|
||||||
|
<p className="text-[11.5px] text-text-4">Try a different keyword, or clear the search to browse everything.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionNav({
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
}: {
|
||||||
|
active: string
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
query: string
|
||||||
|
onQueryChange: (next: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav className="hidden md:flex flex-col gap-4 w-[224px] shrink-0 sticky top-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={13}
|
||||||
|
stroke={2}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-4 pointer-events-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={e => onQueryChange(e.target.value)}
|
||||||
|
placeholder="Search settings..."
|
||||||
|
className="w-full h-9 pl-9 pr-9 rounded-md bg-elevated/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight text-text-1 placeholder:text-text-4 transition"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onQueryChange('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 grid place-items-center rounded-full text-text-4 hover:text-text-1 hover:bg-elevated transition"
|
||||||
|
>
|
||||||
|
<X size={11} stroke={2} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{CATEGORY_GROUPS.map(group => (
|
||||||
|
<li key={group.label}>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-4 mb-1.5 ml-3">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{group.items.map(c => {
|
||||||
|
const isActive = active === c.id
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
className={`relative w-full flex items-center gap-2.5 h-9 pl-3 pr-3 rounded-md transition-colors duration-150 focus-ring ${
|
||||||
|
isActive ? 'bg-glass-light text-accent' : 'text-text-3 hover:text-text-1 hover:bg-glass-light/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="settings-active-rail"
|
||||||
|
className="absolute left-0 top-1.5 bottom-1.5 w-[2.5px] rounded-r-full bg-accent shadow-[0_0_6px_rgba(245,182,66,0.55)]"
|
||||||
|
transition={{ type: 'spring', stiffness: 380, damping: 32 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<c.icon size={14} stroke={isActive ? 2.2 : 1.75} className="shrink-0" />
|
||||||
|
<span className="text-[12.5px] font-medium tracking-tight truncate">{c.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Clock, Flame, Film, Tv, Award, Activity, CalendarStar } from '../lib/icons'
|
||||||
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
||||||
|
import { useDiary } from '../stores/diary-store'
|
||||||
|
import { genreBreakdown, totalHoursWatched, longestBinge, watchStreak } from '../lib/watch-stats'
|
||||||
|
import {
|
||||||
|
hoursPerGenre,
|
||||||
|
hoursPerStudio,
|
||||||
|
completion,
|
||||||
|
topByPersonRole,
|
||||||
|
totalTimeSavedSeconds,
|
||||||
|
} from '../lib/stats'
|
||||||
|
import { formatTimeSaved } from '../lib/time-saved'
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed stats / year-in-watching. Profile page surfaces the at-a-glance
|
||||||
|
* summary; this one digs deeper into hours-weighted breakdowns, top
|
||||||
|
* directors / actors, completion ratios, and the time-saved-by-skipping
|
||||||
|
* total across every series.
|
||||||
|
*/
|
||||||
|
export default function StatsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data, isLoading } = useLibraryItems(undefined, {
|
||||||
|
includeItemTypes: ['Movie', 'Series', 'Episode'],
|
||||||
|
sortBy: ['DatePlayed'],
|
||||||
|
sortOrder: ['Descending'],
|
||||||
|
filters: ['IsPlayed'],
|
||||||
|
limit: 2000,
|
||||||
|
includePeople: true,
|
||||||
|
})
|
||||||
|
const items = useMemo(() => data?.Items || [], [data?.Items])
|
||||||
|
|
||||||
|
const yearItems = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter(it => {
|
||||||
|
const raw = it.UserData?.LastPlayedDate
|
||||||
|
if (!raw) return false
|
||||||
|
return new Date(raw).getFullYear() === CURRENT_YEAR
|
||||||
|
}),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalHoursAll = useMemo(() => totalHoursWatched(items), [items])
|
||||||
|
const totalHoursYear = useMemo(() => totalHoursWatched(yearItems), [yearItems])
|
||||||
|
const streak = useMemo(() => watchStreak(items), [items])
|
||||||
|
const binge = useMemo(() => longestBinge(items), [items])
|
||||||
|
const genreShare = useMemo(() => genreBreakdown(items).slice(0, 10), [items])
|
||||||
|
const hoursGenre = useMemo(() => hoursPerGenre(items).slice(0, 10), [items])
|
||||||
|
const hoursStudio = useMemo(() => hoursPerStudio(items).slice(0, 8), [items])
|
||||||
|
const comp = useMemo(() => completion(items), [items])
|
||||||
|
const topDirectors = useMemo(
|
||||||
|
() => topByPersonRole(items, (_role, type) => type === 'Director', 8),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const topActors = useMemo(
|
||||||
|
() => topByPersonRole(items, (_role, type) => type === 'Actor', 10),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const timeSaved = useMemo(() => totalTimeSavedSeconds(), [])
|
||||||
|
const diary = useDiary(s => s.entries)
|
||||||
|
const diaryYearCount = useMemo(
|
||||||
|
() =>
|
||||||
|
diary.filter(d => {
|
||||||
|
const t = Date.parse(d.watchedAt)
|
||||||
|
return Number.isFinite(t) && new Date(t).getFullYear() === CURRENT_YEAR
|
||||||
|
}).length,
|
||||||
|
[diary],
|
||||||
|
)
|
||||||
|
|
||||||
|
const moviesCount = useMemo(() => items.filter(i => i.Type === 'Movie').length, [items])
|
||||||
|
const episodesCount = useMemo(() => items.filter(i => i.Type === 'Episode').length, [items])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-7 pb-12 pt-6">
|
||||||
|
<header className="mb-8">
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-1.5">
|
||||||
|
Stats
|
||||||
|
</p>
|
||||||
|
<h1 className="font-display text-3xl font-bold tracking-tight text-text-1 leading-tight">
|
||||||
|
Your year in watching
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-text-3 mt-1.5">
|
||||||
|
A deeper look at everything you've played - {items.length.toLocaleString()} {items.length === 1 ? 'item' : 'items'} tracked.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && items.length === 0 && (
|
||||||
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
||||||
|
<p className="text-[13px] text-text-2 font-medium">Crunching the numbers...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Headline tiles */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3 mb-10">
|
||||||
|
<StatTile
|
||||||
|
label={`Hours in ${CURRENT_YEAR}`}
|
||||||
|
value={totalHoursYear.toFixed(0)}
|
||||||
|
unit="hours"
|
||||||
|
icon={<Clock size={14} className="text-accent" />}
|
||||||
|
hint={`${totalHoursAll.toFixed(0)} all-time`}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Movies played"
|
||||||
|
value={moviesCount.toString()}
|
||||||
|
icon={<Film size={14} className="text-accent" />}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Episodes played"
|
||||||
|
value={episodesCount.toString()}
|
||||||
|
icon={<Tv size={14} className="text-accent" />}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Time saved skipping"
|
||||||
|
value={formatTimeSaved(timeSaved.total)}
|
||||||
|
icon={<Activity size={14} className="text-accent" />}
|
||||||
|
hint={timeSaved.series > 0 ? `across ${timeSaved.series} ${timeSaved.series === 1 ? 'show' : 'shows'}` : 'no skips yet'}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Current streak"
|
||||||
|
value={String(streak.current)}
|
||||||
|
unit={streak.current === 1 ? 'day' : 'days'}
|
||||||
|
icon={<Flame size={14} className="text-accent" />}
|
||||||
|
hint={streak.longest > streak.current ? `Best: ${streak.longest}` : 'Keep going'}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Diary entries"
|
||||||
|
value={String(diaryYearCount)}
|
||||||
|
unit={diaryYearCount === 1 ? `entry in ${CURRENT_YEAR}` : `entries in ${CURRENT_YEAR}`}
|
||||||
|
icon={<CalendarStar size={14} className="text-accent" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column: hours per genre + hours per studio */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
||||||
|
<Section title="Hours by genre" empty={hoursGenre.length === 0 ? 'No genre runtime data yet.' : null}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hoursGenre.map((g, i) => (
|
||||||
|
<Bar key={g.label} label={g.label} share={g.share} index={i} suffix={`${g.hours.toFixed(0)}h`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<Section title="Top studios" empty={hoursStudio.length === 0 ? 'No studio info on your library.' : null}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hoursStudio.map((s, i) => (
|
||||||
|
<Bar key={s.label} label={s.label} share={s.share} index={i} suffix={`${s.hours.toFixed(0)}h`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completion ratios */}
|
||||||
|
<Section title="Completion" className="mb-10">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<CompletionTile label="Finished" value={comp.completed} total={comp.total} accent />
|
||||||
|
<CompletionTile label="In progress" value={comp.inProgress} total={comp.total} />
|
||||||
|
<CompletionTile label="Untouched" value={comp.unstarted} total={comp.total} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[12px] text-text-3 tabular-nums">
|
||||||
|
{(comp.completionRate * 100).toFixed(1)}% of tracked items reached the finish line.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* People */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
||||||
|
<Section title="Most-watched directors" empty={topDirectors.length === 0 ? 'No director credits on items.' : null}>
|
||||||
|
<PersonList rows={topDirectors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
|
||||||
|
</Section>
|
||||||
|
<Section title="Most-seen actors" empty={topActors.length === 0 ? 'No cast info on items.' : null}>
|
||||||
|
<PersonList rows={topActors} onClick={n => navigate(`/search?q=${encodeURIComponent(n)}`)} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side note: longest binge + breakdown */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
|
||||||
|
<Section title="Longest binge">
|
||||||
|
{binge.count > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[28px] font-display font-bold text-text-1 tabular-nums leading-none">
|
||||||
|
{binge.count} <span className="text-[14px] font-medium text-text-3">items in one day</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[12.5px] text-text-3 mt-2">
|
||||||
|
On {binge.day ? new Date(binge.day + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) : 'unknown'}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12.5px] text-text-3">No watched items yet.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Section title="Genre share (by count)" empty={genreShare.length === 0 ? null : null}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{genreShare.map((g, i) => (
|
||||||
|
<Bar key={g.genre} label={g.genre} share={g.share} index={i} suffix={`${g.count}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time saved breakdown */}
|
||||||
|
<Section title="Time saved" className="mb-10">
|
||||||
|
{timeSaved.total > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<BadgeTile label="Total" value={formatTimeSaved(timeSaved.total)} icon={<Award size={14} className="text-accent" />} />
|
||||||
|
<BadgeTile label="Intros skipped" value={formatTimeSaved(timeSaved.intros)} />
|
||||||
|
<BadgeTile label="Credits skipped" value={formatTimeSaved(timeSaved.credits)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12.5px] text-text-3">No auto-skips recorded yet. Turn intro / credits skipping on in Playback settings.</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
hint,
|
||||||
|
icon,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit?: string
|
||||||
|
hint?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
accent?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className={`rounded-xl p-4 ring-1 ${
|
||||||
|
accent ? 'bg-accent/10 ring-accent/30' : 'bg-elevated/40 ring-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-2">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="font-display text-[26px] font-bold text-text-1 tabular-nums leading-none">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{unit && <span className="text-[12px] text-text-3 tracking-tight">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
{hint && <p className="text-[11px] text-text-4 mt-1.5 tracking-tight">{hint}</p>}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
empty,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
empty?: string | null
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={`rounded-xl bg-elevated/30 border border-border p-5 ${className || ''}`}>
|
||||||
|
<h2 className="text-[13px] font-semibold text-text-1 mb-3 tracking-tight">{title}</h2>
|
||||||
|
{empty ? <p className="text-[12.5px] text-text-3">{empty}</p> : children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bar({
|
||||||
|
label,
|
||||||
|
share,
|
||||||
|
index,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
share: number
|
||||||
|
index: number
|
||||||
|
suffix?: string
|
||||||
|
}) {
|
||||||
|
const pct = Math.max(2, Math.round(share * 100))
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -6 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: Math.min(index * 0.04, 0.4) }}
|
||||||
|
className="flex items-center gap-3 text-[12px]"
|
||||||
|
>
|
||||||
|
<span className="w-32 shrink-0 text-text-2 font-medium tracking-tight truncate">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-2.5 rounded-full bg-elevated/60 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="h-full bg-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 shrink-0 text-right text-text-3 tabular-nums">
|
||||||
|
{suffix ?? `${pct}%`}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompletionTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
total,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
total: number
|
||||||
|
accent?: boolean
|
||||||
|
}) {
|
||||||
|
const pct = total > 0 ? Math.round((value / total) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg p-4 ring-1 ${accent ? 'bg-accent/8 ring-accent/25' : 'bg-elevated/40 ring-border'}`}>
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">{label}</p>
|
||||||
|
<p className="font-display text-[22px] font-bold text-text-1 tabular-nums leading-none">
|
||||||
|
{value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-text-4 mt-1 tabular-nums">{pct}%</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonList({
|
||||||
|
rows,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
rows: { name: string; count: number }[]
|
||||||
|
onClick: (name: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{rows.map((p, i) => (
|
||||||
|
<li key={p.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => onClick(p.name)}
|
||||||
|
className="w-full flex items-center gap-3 px-2 py-1.5 rounded-md hover:bg-white/4 transition-colors text-left focus-ring"
|
||||||
|
>
|
||||||
|
<span className="w-6 h-6 grid place-items-center rounded-full bg-accent/15 text-accent text-[10.5px] font-bold tabular-nums shrink-0">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-[12.5px] text-text-1 font-medium truncate tracking-tight">
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-text-3 tabular-nums">
|
||||||
|
{p.count} {p.count === 1 ? 'item' : 'items'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-3.5 ring-1 ring-border bg-elevated/40">
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5 flex items-center gap-1.5">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="font-display text-[20px] font-bold text-text-1 tabular-nums leading-none">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
ArrowLeft, Clock, Star, Calendar, ExternalLink, Globe, Library,
|
||||||
|
} from '../lib/icons'
|
||||||
|
import { useTmdbDetailEnrichment } from '../hooks/use-tmdb-detail'
|
||||||
|
import { useLibraryByTmdbId } from '../hooks/use-jellyfin'
|
||||||
|
import { useFanartMovie, useFanartTv } from '../hooks/use-external'
|
||||||
|
import { getTmdbImageUrl, pickTmdbLogo } from '../api/tmdb'
|
||||||
|
import { pickBestFanartImage } from '../api/fanart'
|
||||||
|
import { mapTmdbToJf } from '../lib/tmdb-mapping'
|
||||||
|
import ContentRow from '../components/ui/ContentRow'
|
||||||
|
import CrewGrid from '../components/detail/CrewGrid'
|
||||||
|
import ComposerBlock from '../components/detail/ComposerBlock'
|
||||||
|
import VideosSection from '../components/detail/VideosSection'
|
||||||
|
import AwardsBlock from '../components/detail/AwardsBlock'
|
||||||
|
import ProductionTrivia from '../components/detail/ProductionTrivia'
|
||||||
|
import FilmingLocationsMap from '../components/detail/FilmingLocationsMap'
|
||||||
|
import CollectionStrip from '../components/detail/CollectionStrip'
|
||||||
|
import ReadingMode from '../components/detail/ReadingMode'
|
||||||
|
import DetailStickyBar from '../components/detail/DetailStickyBar'
|
||||||
|
import { usePastSentinel } from '../hooks/use-past-sentinel'
|
||||||
|
import RequestButton from '../components/request/RequestButton'
|
||||||
|
import AvailabilityChip from '../components/ui/AvailabilityChip'
|
||||||
|
import HorizontalScroller from '../components/ui/HorizontalScroller'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tmdbId: number
|
||||||
|
kind: 'movie' | 'tv'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail page for items the user does NOT have in their library. We
|
||||||
|
* render purely from TMDB metadata so users can browse missing canon,
|
||||||
|
* recommendations, awards-row picks, etc. without hitting Jellyfin.
|
||||||
|
*
|
||||||
|
* Mirrors the local DetailPage's chrome where it makes sense (cast,
|
||||||
|
* crew, videos, awards, trivia) but drops sections that need a local
|
||||||
|
* file (Play, Resume, Personal, Diary, Episodes list). The primary
|
||||||
|
* action becomes Request when *arr is configured.
|
||||||
|
*/
|
||||||
|
export default function TmdbDetailPage({ tmdbId, kind }: Props) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { sentinelRef: stickySentinelRef, past: pastHero } = usePastSentinel()
|
||||||
|
const enrichment = useTmdbDetailEnrichment({ kind, tmdbId })
|
||||||
|
const { data, isLoading, collection: tmdbCollection, awards: awardsQuery, locations: locationsQuery, wikiProduction: wikiProductionQuery } = enrichment
|
||||||
|
|
||||||
|
const libraryByTmdbId = useLibraryByTmdbId()
|
||||||
|
|
||||||
|
// Logo source priority: fanart.tv (cleaner art, typically transparent
|
||||||
|
// PNG wordmarks) → TMDB logos. Mirrors the library DetailHero's chain
|
||||||
|
// (jellyfin → fanart) but without the jellyfin source since this page
|
||||||
|
// exists precisely for items not in the library.
|
||||||
|
const tvdbId = (enrichment.data as any)?.external_ids?.tvdb_id
|
||||||
|
const fanartMovieQuery = useFanartMovie(kind === 'movie' ? String(tmdbId) : null)
|
||||||
|
const fanartTvQuery = useFanartTv(kind === 'tv' && tvdbId ? String(tvdbId) : null)
|
||||||
|
const fanartLogo = pickBestFanartImage(
|
||||||
|
kind === 'movie'
|
||||||
|
? fanartMovieQuery.data?.hdmovielogo || fanartMovieQuery.data?.movielogo
|
||||||
|
: fanartTvQuery.data?.hdtvlogo || fanartTvQuery.data?.clearlogo,
|
||||||
|
)
|
||||||
|
const tmdbLogo = pickTmdbLogo(enrichment.data?.images?.logos)
|
||||||
|
const logoUrl =
|
||||||
|
fanartLogo?.url ||
|
||||||
|
(tmdbLogo ? getTmdbImageUrl(tmdbLogo.file_path, 'w500') : null)
|
||||||
|
|
||||||
|
const [readingModeOpen, setReadingModeOpen] = useState(false)
|
||||||
|
|
||||||
|
// Library cross-reference: did the item get pulled into Jellyfin since
|
||||||
|
// we landed on this page? If so, offer a "View in your library" button.
|
||||||
|
const matchedLocal = libraryByTmdbId.data?.get(String(tmdbId)) || null
|
||||||
|
|
||||||
|
// Recommendations + similar rows (cross-referenced with library so
|
||||||
|
// already-owned items get the In Library indicator).
|
||||||
|
const recommendations = useMemo(() => {
|
||||||
|
const list = data?.recommendations?.results || []
|
||||||
|
return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
|
||||||
|
}, [data, libraryByTmdbId.data])
|
||||||
|
const similar = useMemo(() => {
|
||||||
|
const list = data?.similar?.results || []
|
||||||
|
return mapTmdbToJf(list.slice(0, 18), libraryByTmdbId.data)
|
||||||
|
}, [data, libraryByTmdbId.data])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-6">
|
||||||
|
<p className="text-[15px] text-text-1 font-medium mb-1.5">No TMDB record</p>
|
||||||
|
<p className="text-[12px] text-text-3 max-w-md leading-relaxed mb-5">
|
||||||
|
We couldn't load metadata for this title. It may have been removed from TMDB.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-elevated/60 ring-1 ring-border hover:ring-border-strong text-[12px] text-text-2 hover:text-text-1 transition focus-ring"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={12} stroke={2} />
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = data.title || data.name || ''
|
||||||
|
const year = (data.release_date || data.first_air_date || '').slice(0, 4)
|
||||||
|
const runtime = kind === 'movie'
|
||||||
|
? data.runtime
|
||||||
|
: data.episode_run_time?.[0]
|
||||||
|
const overview = data.overview || ''
|
||||||
|
const tagline = data.tagline
|
||||||
|
const genres = data.genres || []
|
||||||
|
const community = data.vote_average
|
||||||
|
const certification = (() => {
|
||||||
|
if (kind === 'movie') {
|
||||||
|
const us = data.release_dates?.results?.find(r => r.iso_3166_1 === 'US')
|
||||||
|
return us?.release_dates?.find(d => d.certification)?.certification
|
||||||
|
}
|
||||||
|
return data.content_ratings?.results?.find(r => r.iso_3166_1 === 'US')?.rating
|
||||||
|
})()
|
||||||
|
const credits = data.credits
|
||||||
|
const cast = (credits?.cast || []).slice(0, 18)
|
||||||
|
const crew = credits?.crew || []
|
||||||
|
const backdrop = data.backdrop_path
|
||||||
|
? getTmdbImageUrl(data.backdrop_path, 'original')
|
||||||
|
: null
|
||||||
|
const poster = data.poster_path
|
||||||
|
? getTmdbImageUrl(data.poster_path, 'w500')
|
||||||
|
: null
|
||||||
|
const tmdbWatchUrl = `https://www.themoviedb.org/${kind === 'movie' ? 'movie' : 'tv'}/${tmdbId}`
|
||||||
|
const keywords = (data.keywords?.keywords || data.keywords?.results || []) as Array<{ id: number; name: string }>
|
||||||
|
|
||||||
|
// If the item was pulled into the library, route Play to the local
|
||||||
|
// playback URL; otherwise the sticky bar's Play button has nothing
|
||||||
|
// to do, so we hide it by passing an empty url and a no-op resume.
|
||||||
|
const playUrl = matchedLocal ? `/play/${matchedLocal.id}` : ''
|
||||||
|
const tmdbImdbId = (data as any).external_ids?.imdb_id || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
{matchedLocal && (
|
||||||
|
<DetailStickyBar
|
||||||
|
visible={pastHero}
|
||||||
|
title={title}
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
posterUrl={poster}
|
||||||
|
progress={null}
|
||||||
|
isFavorite={false}
|
||||||
|
playUrl={playUrl}
|
||||||
|
resumeUrl={playUrl}
|
||||||
|
tmdbId={tmdbId}
|
||||||
|
imdbId={tmdbImdbId}
|
||||||
|
itemType={kind === 'tv' ? 'Series' : 'Movie'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="relative -mt-14 mb-2 overflow-hidden">
|
||||||
|
{backdrop && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 1.04 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="absolute inset-0 bg-cover bg-top will-change-transform"
|
||||||
|
style={{ backgroundImage: `url(${backdrop})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/60 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-void via-void/30 to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative px-7 pt-32 pb-10 grid grid-cols-1 md:grid-cols-[180px_1fr] gap-8">
|
||||||
|
{poster && (
|
||||||
|
<motion.img
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
src={poster}
|
||||||
|
alt={title}
|
||||||
|
className="w-[180px] aspect-[2/3] rounded-xl object-cover ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] hidden md:block"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
|
||||||
|
className="max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-cool/12 border border-cool/30 text-cool text-[10px] font-semibold uppercase tracking-[0.14em]">
|
||||||
|
<Globe size={10} />
|
||||||
|
Discovery
|
||||||
|
</span>
|
||||||
|
<AvailabilityChip tmdbId={tmdbId} />
|
||||||
|
{matchedLocal && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/item/${matchedLocal.id}`)}
|
||||||
|
className="inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-accent text-void text-[10px] font-bold uppercase tracking-[0.06em] hover:bg-accent-hover transition"
|
||||||
|
>
|
||||||
|
<Library size={10} strokeWidth={2.5} />
|
||||||
|
In your library
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logoUrl ? (
|
||||||
|
<div className="mb-4 flex justify-start">
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={title}
|
||||||
|
className="block h-auto max-h-32 md:max-h-40 max-w-[460px] w-auto drop-shadow-[0_4px_24px_rgba(0,0,0,0.7)]"
|
||||||
|
onError={e => {
|
||||||
|
const el = e.target as HTMLImageElement
|
||||||
|
el.style.display = 'none'
|
||||||
|
// Reveal the text fallback if the image fails to load.
|
||||||
|
const fallback = el.nextElementSibling as HTMLElement | null
|
||||||
|
if (fallback) fallback.style.display = 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h1
|
||||||
|
className="font-display text-4xl md:text-5xl font-bold text-white leading-[0.98] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)] hidden"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h1 className="font-display text-4xl md:text-5xl font-bold text-white leading-[0.98] tracking-tight mb-3 drop-shadow-[0_4px_16px_rgba(0,0,0,0.6)]">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tagline && (
|
||||||
|
<p className="text-[15px] text-white/70 italic mb-3 max-w-xl font-display">
|
||||||
|
"{tagline}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 text-[12px] text-white/75 mb-3 flex-wrap font-medium">
|
||||||
|
{year && <span className="tabular-nums">{year}</span>}
|
||||||
|
{certification && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="px-1.5 py-0.5 border border-white/25 rounded text-[10px] font-semibold tracking-wide">
|
||||||
|
{certification}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{runtime != null && runtime > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||||
|
<Clock size={11} stroke={2} />
|
||||||
|
{runtime}m
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{community != null && community > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Star size={11} className="text-accent" fill="currentColor" stroke={0} />
|
||||||
|
<span className="tabular-nums">{Number(community).toFixed(1)}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{kind === 'tv' && (data as any).number_of_seasons > 0 && (
|
||||||
|
<>
|
||||||
|
<Dot />
|
||||||
|
<span className="inline-flex items-center gap-1 tabular-nums">
|
||||||
|
<Calendar size={11} stroke={2} />
|
||||||
|
{(data as any).number_of_seasons} season{(data as any).number_of_seasons === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{genres.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-5">
|
||||||
|
{genres.slice(0, 5).map(g => (
|
||||||
|
<span
|
||||||
|
key={g.id}
|
||||||
|
className="px-2.5 h-6 inline-flex items-center bg-white/8 backdrop-blur text-white/85 text-[11px] rounded-full border border-white/8"
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overview && (
|
||||||
|
<p className="text-[14px] text-white/85 leading-[1.65] mb-6 line-clamp-4 max-w-xl drop-shadow-sm">
|
||||||
|
{overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<RequestButton tmdbId={tmdbId} kind={kind} tmdbData={data as any} />
|
||||||
|
{matchedLocal && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/item/${matchedLocal.id}`)}
|
||||||
|
className="h-11 px-5 bg-white/10 text-white border border-white/20 backdrop-blur rounded-lg flex items-center gap-2 text-[13px] font-medium transition-all duration-200 hover:bg-white/15 hover:border-white/30 hover:scale-[1.02] active:scale-[0.98] focus-ring"
|
||||||
|
>
|
||||||
|
<Library size={14} stroke={2} />
|
||||||
|
Open in library
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={tmdbWatchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Open on TMDB
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div ref={stickySentinelRef} aria-hidden className="h-px -mt-px" />
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-7 space-y-12 pt-4">
|
||||||
|
{overview && (
|
||||||
|
<Section label="Overview">
|
||||||
|
<p className="text-[15px] text-text-2 leading-[1.7] max-w-[68ch] tracking-[-0.005em]">
|
||||||
|
{overview}
|
||||||
|
</p>
|
||||||
|
{overview.length > 600 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setReadingModeOpen(true)}
|
||||||
|
className="mt-2 text-[11.5px] text-accent hover:text-accent-hover transition tracking-tight focus-ring rounded"
|
||||||
|
>
|
||||||
|
Open in reader
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<ReadingMode
|
||||||
|
open={readingModeOpen}
|
||||||
|
onClose={() => setReadingModeOpen(false)}
|
||||||
|
title={title}
|
||||||
|
body={overview}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cast.length > 0 && (
|
||||||
|
<Section label="Cast">
|
||||||
|
<CastStrip cast={cast} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crew.length > 0 && (
|
||||||
|
<Section label="Crew">
|
||||||
|
<CrewGrid crew={crew} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<ComposerBlock crew={crew} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{((data as any).videos?.results?.length ?? 0) > 0 && (
|
||||||
|
<Section label="Videos">
|
||||||
|
<VideosSection videos={(data as any).videos?.results} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(awardsQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<Section label="Awards">
|
||||||
|
<AwardsBlock awards={awardsQuery.data} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(wikiProductionQuery.data?.extract || (keywords.length > 0 && wikiProductionQuery.data)) && (
|
||||||
|
<Section label="Trivia">
|
||||||
|
<ProductionTrivia keywords={keywords} wikiProduction={wikiProductionQuery.data} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(locationsQuery.data?.length ?? 0) > 0 && (
|
||||||
|
<Section label="Filming locations">
|
||||||
|
<FilmingLocationsMap locations={locationsQuery.data} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'movie' && (data as any).belongs_to_collection && (
|
||||||
|
<Section label="Collection">
|
||||||
|
<CollectionStrip
|
||||||
|
collectionRef={(data as any).belongs_to_collection}
|
||||||
|
collection={tmdbCollection.data}
|
||||||
|
currentMovieId={tmdbId}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations / similar */}
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<ContentRow
|
||||||
|
title="More like this"
|
||||||
|
subtitle="TMDB recommendations"
|
||||||
|
items={recommendations}
|
||||||
|
layoutKey={`tmdb_${kind}_${tmdbId}_recs`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{similar.length > 0 && (
|
||||||
|
<ContentRow
|
||||||
|
title="Similar"
|
||||||
|
subtitle="Same flavour, different titles"
|
||||||
|
items={similar}
|
||||||
|
layoutKey={`tmdb_${kind}_${tmdbId}_similar`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-text-3 mb-4 leading-none">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CastStrip({ cast }: { cast: any[] }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<div className="-mx-7">
|
||||||
|
<HorizontalScroller gap="gap-3" arrowsBottomInset="bottom-12">
|
||||||
|
{cast.map(c => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => navigate(`/person/${c.id}`)}
|
||||||
|
className="shrink-0 w-[112px] text-left focus-ring rounded-lg group"
|
||||||
|
>
|
||||||
|
<div className="aspect-[2/3] rounded-lg overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-border-strong mb-1.5 transition">
|
||||||
|
{c.profile_path ? (
|
||||||
|
<img
|
||||||
|
src={getTmdbImageUrl(c.profile_path, 'w185')}
|
||||||
|
alt={c.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full grid place-items-center text-text-4 text-2xl font-display font-semibold opacity-40">
|
||||||
|
{c.name?.[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-text-1 font-medium leading-tight tracking-tight truncate">
|
||||||
|
{c.name}
|
||||||
|
</p>
|
||||||
|
{c.character && (
|
||||||
|
<p className="text-[10.5px] text-text-3 leading-tight truncate">
|
||||||
|
{c.character}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</HorizontalScroller>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton() {
|
||||||
|
return (
|
||||||
|
<div className="pb-12">
|
||||||
|
<div className="relative -mt-14 mb-2 h-[60vh] min-h-[440px] overflow-hidden">
|
||||||
|
<div className="absolute inset-0 skeleton" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-void via-void/55 to-transparent" />
|
||||||
|
</div>
|
||||||
|
<div className="px-7 space-y-8 pt-6">
|
||||||
|
<div className="skeleton h-5 w-32 rounded" />
|
||||||
|
<div className="skeleton h-4 w-full rounded" />
|
||||||
|
<div className="skeleton h-4 w-5/6 rounded" />
|
||||||
|
<div className="skeleton h-4 w-2/3 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot() {
|
||||||
|
return <span className="text-white/30">·</span>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user