From 02f0f58ec9e1077bfd1cb82af920a7364c9f7bc8 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Mar 2026 02:58:46 +0200 Subject: [PATCH] shared ui: poster cards, content rows, scrollers, lazy mount --- src/components/layout/AppHeader.tsx | 329 +++++++++++++++++ src/components/layout/AppShell.tsx | 419 +++++++++++++++++++++ src/components/layout/Titlebar.tsx | 88 +++++ src/components/ui/AvailabilityChip.tsx | 56 +++ src/components/ui/BatchActionsBar.tsx | 119 ++++++ src/components/ui/BrandLogo.tsx | 52 +++ src/components/ui/BrandRow.tsx | 45 +++ src/components/ui/CanonListRow.tsx | 92 +++++ src/components/ui/Chip.tsx | 79 ++++ src/components/ui/ContentRow.tsx | 308 ++++++++++++++++ src/components/ui/ErrorBoundary.tsx | 53 +++ src/components/ui/HorizontalScroller.tsx | 122 +++++++ src/components/ui/LazyMount.tsx | 146 ++++++++ src/components/ui/LetterboxdAddModal.tsx | 97 +++++ src/components/ui/LetterboxdListRow.tsx | 158 ++++++++ src/components/ui/MediaTechIcons.tsx | 416 +++++++++++++++++++++ src/components/ui/MetaBadges.tsx | 84 +++++ src/components/ui/OfflineBanner.tsx | 51 +++ src/components/ui/PersonSpotlightRow.tsx | 129 +++++++ src/components/ui/PosterCard.tsx | 445 +++++++++++++++++++++++ src/components/ui/QuickLookModal.tsx | 201 ++++++++++ src/components/ui/SectionLabel.tsx | 40 ++ src/components/ui/Select.tsx | 137 +++++++ src/components/ui/SmartShelfRow.tsx | 95 +++++ src/components/ui/SmartShelfWizard.tsx | 268 ++++++++++++++ src/components/ui/StatTile.tsx | 45 +++ src/components/ui/SwipeReveal.tsx | 125 +++++++ src/components/ui/ToastHost.tsx | 39 ++ src/components/ui/WatchlistButton.tsx | 66 ++++ src/components/ui/YoutubeViewerModal.tsx | 102 ++++++ src/components/ui/brand-marks.tsx | 28 ++ 31 files changed, 4434 insertions(+) create mode 100644 src/components/layout/AppHeader.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/Titlebar.tsx create mode 100644 src/components/ui/AvailabilityChip.tsx create mode 100644 src/components/ui/BatchActionsBar.tsx create mode 100644 src/components/ui/BrandLogo.tsx create mode 100644 src/components/ui/BrandRow.tsx create mode 100644 src/components/ui/CanonListRow.tsx create mode 100644 src/components/ui/Chip.tsx create mode 100644 src/components/ui/ContentRow.tsx create mode 100644 src/components/ui/ErrorBoundary.tsx create mode 100644 src/components/ui/HorizontalScroller.tsx create mode 100644 src/components/ui/LazyMount.tsx create mode 100644 src/components/ui/LetterboxdAddModal.tsx create mode 100644 src/components/ui/LetterboxdListRow.tsx create mode 100644 src/components/ui/MediaTechIcons.tsx create mode 100644 src/components/ui/MetaBadges.tsx create mode 100644 src/components/ui/OfflineBanner.tsx create mode 100644 src/components/ui/PersonSpotlightRow.tsx create mode 100644 src/components/ui/PosterCard.tsx create mode 100644 src/components/ui/QuickLookModal.tsx create mode 100644 src/components/ui/SectionLabel.tsx create mode 100644 src/components/ui/Select.tsx create mode 100644 src/components/ui/SmartShelfRow.tsx create mode 100644 src/components/ui/SmartShelfWizard.tsx create mode 100644 src/components/ui/StatTile.tsx create mode 100644 src/components/ui/SwipeReveal.tsx create mode 100644 src/components/ui/ToastHost.tsx create mode 100644 src/components/ui/WatchlistButton.tsx create mode 100644 src/components/ui/YoutubeViewerModal.tsx create mode 100644 src/components/ui/brand-marks.tsx diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..a9d9d60 --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,329 @@ +import { useEffect, useState } from 'react' +import { NavLink, useLocation, useNavigate } from 'react-router-dom' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { motion, AnimatePresence } from 'framer-motion' +import { + Search, + ArrowLeft, + Pin, + PinFilled, + Minus, + Square, + RestoreDown, + X, + Bell, +} from '../../lib/icons' +import { isTauri } from '../../lib/tauri' +import { jellyfinClient, getActivityLogApi } from '../../api/jellyfin' + +/** + * The single chrome bar at the top of the in-app shell. Replaces the + * previous trio of (1) Tauri titlebar with brand + window controls, (2) + * sidebar brand row, and (3) TopBar with back button + page title + + * search. One bar covers all of them. + * + * The whole thing is a Tauri drag region, so the user can grab any empty + * stretch (logo, title, padding) to move the window. Buttons opt out + * automatically since they sit on top. + */ + +const TOP_LEVEL_PATHS = new Set([ + '/', + '/movies', + '/shows', + '/playlists', + '/music', + '/search', + '/discover', + '/settings', +]) + +function pageTitleFor(pathname: string): string { + if (pathname === '/') return 'Home' + if (pathname === '/movies') return 'Movies' + if (pathname === '/shows') return 'TV Shows' + if (pathname === '/playlists') return 'Playlists' + if (pathname === '/music') return 'Music' + if (pathname === '/search') return 'Search' + if (pathname === '/discover') return 'Discover' + if (pathname === '/settings') return 'Settings' + return '' +} + +interface Props { + pinned: boolean + onTogglePin: () => void +} + +export default function AppHeader({ pinned, onTogglePin }: Props) { + const navigate = useNavigate() + const location = useLocation() + const [scrolled, setScrolled] = useState(false) + const [maximized, setMaximized] = useState(false) + const [notifsOpen, setNotifsOpen] = useState(false) + const [notifs, setNotifs] = useState([]) + + // Poll activity log for the notification bell + useEffect(() => { + const api = jellyfinClient.getApi() + if (!api) return + async function poll() { + try { + const res = await getActivityLogApi(api!).getLogEntries({ limit: 8 }) + setNotifs(res.data.Items || []) + } catch { /* ignore */ } + } + poll() + const id = setInterval(poll, 60_000) + return () => clearInterval(id) + }, []) + + // Subtle background fade in when the main pane is scrolled - same UX + // affordance the old TopBar had, just on the unified header. + useEffect(() => { + const main = document.querySelector('main.content-scroll') as HTMLElement | null + if (!main) { + setScrolled(false) + return + } + const onScroll = () => setScrolled(main.scrollTop > 4) + main.addEventListener('scroll', onScroll, { passive: true }) + onScroll() + return () => main.removeEventListener('scroll', onScroll) + }, [location.pathname]) + + // Ctrl+K opens search + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault() + navigate('/search') + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [navigate]) + + // Track maximize state so the maximize icon flips to a restore icon when + // the window is full-screen. Only meaningful in Tauri. + useEffect(() => { + if (!isTauri) return + let cancelled = false + let unlisten: (() => void) | undefined + const win = getCurrentWindow() + win.isMaximized().then(v => { + if (!cancelled) setMaximized(v) + }).catch(() => {}) + win.onResized(async () => { + const v = await win.isMaximized() + if (!cancelled) setMaximized(v) + }).then(u => { + if (cancelled) u() + else unlisten = u + }).catch(() => {}) + return () => { + cancelled = true + unlisten?.() + } + }, []) + + function callWin(action: 'minimize' | 'toggleMaximize' | 'close') { + if (!isTauri) return + const win = getCurrentWindow() + if (action === 'minimize') win.minimize() + else if (action === 'toggleMaximize') win.toggleMaximize() + else win.close() + } + + const showBack = !TOP_LEVEL_PATHS.has(location.pathname) + const title = pageTitleFor(location.pathname) + + return ( +
+ {/* ── Left: brand + pin ──────────────────────────────────── */} +
+ +
+
+
+
+ +
+
+ + + JELLYFIN + + + Media client + + + + + +
+ + {/* ── Middle: back button + page title (drag region) ─────── */} +
+ {showBack && ( + + )} + + {title && ( + + {title} + + )} + +
+ + {/* ── Right: search + window controls ─────────────────────── */} +
+ + + {/* Notification bell */} +
+ + + {notifsOpen && ( + +
+

Recent activity

+
+
+ {notifs.length === 0 ? ( +

No recent activity

+ ) : ( + notifs.map((entry: any, i: number) => ( +
+

{entry.Name}

+

+ {entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''} +

+
+ )) + )} +
+
+ )} +
+
+ + {isTauri && ( + <> + callWin('minimize')} aria-label="Minimize"> + + + callWin('toggleMaximize')} + aria-label={maximized ? 'Restore' : 'Maximize'} + > + {maximized ? : } + + callWin('close')} aria-label="Close" variant="close"> + + + + )} +
+
+ ) +} + +function WinButton({ + children, + variant, + ...props +}: React.ButtonHTMLAttributes & { variant?: 'close' }) { + return ( + + ) +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..2b79814 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,419 @@ +import { Suspense, useState, type ComponentType } from 'react' +import { NavLink, Outlet } from 'react-router-dom' +import { motion, LayoutGroup, AnimatePresence } from 'framer-motion' +import * as Tooltip from '@radix-ui/react-tooltip' +import { + Home, + Film, + Tv, + Search, + Compass, + Settings, + LogOut, + Playlists, + User, + Database, + Activity, + Radio, + Download, +} from '../../lib/icons' +import type { AuthState } from '../../api/types' +import AppHeader from './AppHeader' +import MiniPlayer from '../player/MiniPlayer' +import NowPlaying from '../player/NowPlaying' +import OfflineBanner from '../ui/OfflineBanner' +import QuickLookModal from '../ui/QuickLookModal' +import YoutubeViewerModal from '../ui/YoutubeViewerModal' +import RequestModal from '../request/RequestModal' +import ToastHost from '../ui/ToastHost' +import { useRequestModal } from '../../stores/request-modal-store' +import { + useSidebarStore, + SIDEBAR_COLLAPSED_W, + SIDEBAR_DEFAULT_W, + SIDEBAR_MIN_W, + SIDEBAR_MAX_W, +} from '../../stores/sidebar-store' + +interface NavItem { + to: string + icon: ComponentType<{ size?: number; stroke?: number; className?: string }> + label: string + kbd?: string +} + +interface NavSection { + label: string + items: NavItem[] +} + +const NAV_SECTIONS: NavSection[] = [ + { + label: 'Library', + items: [ + { to: '/', icon: Home, label: 'Home' }, + { to: '/movies', icon: Film, label: 'Movies' }, + { to: '/shows', icon: Tv, label: 'Shows' }, + { to: '/playlists', icon: Playlists, label: 'Playlists' }, + { to: '/live', icon: Radio, label: 'Live TV' }, + { to: '/requests', icon: Database, label: 'Requests' }, + { to: '/downloads', icon: Download, label: 'Downloads' }, + ], + }, + { + label: 'Discover', + items: [ + { to: '/discover', icon: Compass, label: 'Discover' }, + { to: '/search', icon: Search, label: 'Search', kbd: 'Ctrl K' }, + ], + }, + { + label: 'Account', + items: [ + { to: '/profile', icon: User, label: 'Profile' }, + { to: '/stats', icon: Activity, label: 'Stats' }, + { to: '/settings', icon: Settings, label: 'Settings' }, + ], + }, +] + +interface Props { + auth: AuthState + onLogout: () => void +} + +export default function AppShell({ auth, onLogout }: Props) { + const pinned = useSidebarStore(s => s.pinned) + const togglePinned = useSidebarStore(s => s.togglePinned) + const pinnedWidth = useSidebarStore(s => s.pinnedWidth) + const setPinnedWidth = useSidebarStore(s => s.setPinnedWidth) + const resetPinnedWidth = useSidebarStore(s => s.resetPinnedWidth) + const [hovered, setHovered] = useState(false) + const [resizing, setResizing] = useState(false) + const [nowPlayingOpen, setNowPlayingOpen] = useState(false) + + const expanded = pinned || hovered + // When pinned the user controls the width; hover-expanded uses the default + const targetWidth = !expanded + ? SIDEBAR_COLLAPSED_W + : pinned + ? pinnedWidth + : SIDEBAR_DEFAULT_W + + function startResize(e: React.PointerEvent) { + if (!pinned) return + e.preventDefault() + setResizing(true) + const startX = e.clientX + const startW = pinnedWidth + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + function onMove(ev: PointerEvent) { + const next = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, startW + (ev.clientX - startX))) + setPinnedWidth(next) + } + function onUp() { + setResizing(false) + document.body.style.cursor = '' + document.body.style.userSelect = '' + document.removeEventListener('pointermove', onMove) + document.removeEventListener('pointerup', onUp) + } + document.addEventListener('pointermove', onMove) + document.addEventListener('pointerup', onUp) + } + + function resetWidth() { + if (!pinned) return + resetPinnedWidth() + } + + return ( + +
+ {/* Single chrome bar at the top: brand + pin + back/title + search + + window controls. Replaces the old sidebar brand row + TopBar + + Tauri-only Titlebar. */} + + +
+ setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + + + + {pinned && ( +
+ +
+ )} +
+ + {/* Main pane */} +
+ +
+ }> + + +
+ setNowPlayingOpen(true)} /> +
+
+ + setNowPlayingOpen(false)} /> + + + + +
+
+ ) +} + +function RequestModalMount() { + const open = useRequestModal(s => s.open) + const tmdbId = useRequestModal(s => s.tmdbId) + const kind = useRequestModal(s => s.kind) + const tmdbData = useRequestModal(s => s.tmdbData) + const close = useRequestModal(s => s.close) + if (!tmdbId) return null + return ( + + ) +} + +function RouteFallback() { + return ( +
+
+ + Loading +
+
+ ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Section + Item */ +/* ──────────────────────────────────────────────────────────── */ + +function Section({ section, expanded }: { section: NavSection; expanded: boolean }) { + return ( +
+ + {expanded && ( + + {section.label} + + )} + +
+ {section.items.map(item => ( + + ))} +
+
+ ) +} + +interface SidebarItemProps { + item: NavItem + expanded: boolean + asButton?: boolean + onClick?: () => void +} + +function SidebarItem({ item, expanded, asButton = false, onClick }: SidebarItemProps) { + const inner = (isActive: boolean) => ( + <> + {/* Left active rail */} + {isActive && ( + + )} + + + + + + + {expanded && ( + + {item.label} + + )} + + + + {expanded && item.kbd && ( + + {item.kbd} + + )} + + + ) + + const baseCls = + 'group relative flex items-center h-10 rounded-lg w-full focus-ring overflow-hidden' + + const node = asButton ? ( + + ) : ( + + `${baseCls} ${isActive ? 'bg-glass-light' : 'hover:bg-glass-light'}` + } + > + {({ isActive }) => inner(isActive)} + + ) + + // Wrap in tooltip - only renders when collapsed + if (expanded) return node + return ( + + {node} + + + {item.label} + {item.kbd && ( + + {item.kbd} + + )} + + + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* User card */ +/* ──────────────────────────────────────────────────────────── */ + +function UserCard({ auth, expanded }: { auth: AuthState; expanded: boolean }) { + const initial = (auth.userName || 'U')[0].toUpperCase() + const serverHost = (() => { + try { + return auth.serverUrl ? new URL(auth.serverUrl).host : '' + } catch { return '' } + })() + + return ( +
+
+
+
+ {initial} +
+ +
+ + {expanded && ( + +

+ {auth.userName || 'User'} +

+ {serverHost && ( +

{serverHost}

+ )} +
+ )} +
+
+
+ ) +} diff --git a/src/components/layout/Titlebar.tsx b/src/components/layout/Titlebar.tsx new file mode 100644 index 0000000..14628be --- /dev/null +++ b/src/components/layout/Titlebar.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { getCurrentWindow } from '@tauri-apps/api/window' +import { Minus, Square, RestoreDown, X } from '../../lib/icons' + +/** + * Custom titlebar for the Tauri desktop shell. With `decorations: false` on + * the window, the OS chrome is gone - this bar provides the dragging region + * (`data-tauri-drag-region`) and the min / max / close affordances. + * + * Themed to match the rest of the app: 36px tall, void-tone background with + * a subtle bottom border, hairline accent on hover for the buttons, classic + * red on the close hover. Title shown center-left. + */ +export default function Titlebar() { + const [maximized, setMaximized] = useState(false) + const win = getCurrentWindow() + + useEffect(() => { + let cancelled = false + win.isMaximized().then(v => { + if (!cancelled) setMaximized(v) + }) + const unlistenP = win.onResized(async () => { + const v = await win.isMaximized() + if (!cancelled) setMaximized(v) + }) + return () => { + cancelled = true + unlistenP.then(u => u()) + } + }, [win]) + + return ( +
+
+ + j + + + Jellyfin + +
+ +
+ win.minimize()} aria-label="Minimize"> + + + win.toggleMaximize()} aria-label={maximized ? 'Restore' : 'Maximize'}> + {maximized ? : } + + win.close()} aria-label="Close" variant="close"> + + +
+
+ ) +} + +function TitlebarButton({ + children, + onClick, + variant, + ...props +}: React.ButtonHTMLAttributes & { variant?: 'close' }) { + return ( + + ) +} diff --git a/src/components/ui/AvailabilityChip.tsx b/src/components/ui/AvailabilityChip.tsx new file mode 100644 index 0000000..7ce7328 --- /dev/null +++ b/src/components/ui/AvailabilityChip.tsx @@ -0,0 +1,56 @@ +import { Check, Clock, RefreshCw, AlertCircle } from '../../lib/icons' +import { useItemAvailability } from '../../hooks/use-availability' + +interface Props { + tmdbId: string | number | null | undefined + /** Compact icon-only mode for poster overlays. */ + variant?: 'pill' | 'icon' +} + +const TONE: Record = { + available: { label: 'Available', color: 'bg-success/20 text-success ring-success/30' }, + partial: { label: 'Partial', color: 'bg-amber-500/20 text-amber-200 ring-amber-400/30' }, + processing: { label: 'Downloading', color: 'bg-blue-500/20 text-blue-200 ring-blue-400/30' }, + pending: { label: 'Pending', color: 'bg-purple-500/20 text-purple-200 ring-purple-400/30' }, + requested: { label: 'Requested', color: 'bg-elevated/80 text-text-2 ring-border' }, +} + +/** + * Renders a small status chip ("Available", "Downloading", "Pending" + * etc.) sourced from `useItemAvailability`. Returns null when the item + * isn't tracked anywhere - rendering "missing" as a chip would just be + * noise on every card. + */ +export default function AvailabilityChip({ tmdbId, variant = 'pill' }: Props) { + const record = useItemAvailability(tmdbId) + if (!record || record.status === 'missing') return null + const tone = TONE[record.status] + if (!tone) return null + + if (variant === 'icon') { + const Icon = + record.status === 'available' ? Check + : record.status === 'processing' ? RefreshCw + : record.status === 'pending' ? Clock + : AlertCircle + return ( + + + + ) + } + + return ( + + {tone.label} + + ) +} diff --git a/src/components/ui/BatchActionsBar.tsx b/src/components/ui/BatchActionsBar.tsx new file mode 100644 index 0000000..60cdc08 --- /dev/null +++ b/src/components/ui/BatchActionsBar.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Check, Heart } from '../../lib/icons' +import { useLibrarySelection } from '../../stores/library-selection-store' +import { useBulkMarkPlayed, useBulkToggleFavorite, useRefreshItem } from '../../hooks/use-jellyfin' +import { useWatchlist } from '../../hooks/use-watchlist' + +/** + * Floating toolbar shown over the bottom of the page when at least one + * library item is multi-selected. Surfaces bulk actions (mark watched, + * unwatched, favorite, add to watchlist) with optimistic UI - mutations + * fire all at once and the React Query invalidation refreshes the + * underlying query. + */ +export default function BatchActionsBar() { + const selected = useLibrarySelection(s => s.selected) + const clear = useLibrarySelection(s => s.clear) + const markPlayed = useBulkMarkPlayed() + const toggleFav = useBulkToggleFavorite() + const refreshItem = useRefreshItem() + const watchlist = useWatchlist() + const [busy, setBusy] = useState(false) + + const ids = Array.from(selected) + const count = ids.length + if (count === 0) return null + + async function run(fn: () => Promise) { + if (busy) return + setBusy(true) + try { + await fn() + clear() + } finally { + setBusy(false) + } + } + + return ( + + {count > 0 && ( + + + {count} selected + + } + onClick={() => run(() => markPlayed.mutateAsync({ itemIds: ids, played: true }))} + /> + run(() => markPlayed.mutateAsync({ itemIds: ids, played: false }))} + /> + } + onClick={() => run(() => toggleFav.mutateAsync({ itemIds: ids, favorite: true }))} + /> + run(async () => { + for (const id of ids) await watchlist.addToWatchlist(id) + })} + /> + run(async () => { + for (const id of ids) { + await refreshItem.mutateAsync({ itemId: id }) + } + })} + /> + + + )} + + ) +} + +function Action({ + label, + icon, + disabled, + onClick, +}: { + label: string + icon?: React.ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} diff --git a/src/components/ui/BrandLogo.tsx b/src/components/ui/BrandLogo.tsx new file mode 100644 index 0000000..f498a3f --- /dev/null +++ b/src/components/ui/BrandLogo.tsx @@ -0,0 +1,52 @@ +import { useLogoTone } from '../../lib/logo-tone' + +interface Props { + src: string + alt: string + /** Inner image height in px - the pad sizes around it */ + height?: number + /** Max image width in px to prevent absurdly wide logos from dominating */ + maxWidth?: number + className?: string +} + +/** + * Renders a remote logo (TMDB network / production company) on a small pad + * whose color is chosen by sampling the logo's luminance: + * - dark-content logos sit on a near-white pad + * - light-content logos sit on a near-black pad + * - unknown / unloaded uses the dark-content default + */ +export default function BrandLogo({ + src, + alt, + height = 14, + maxWidth = 80, + className = '', +}: Props) { + const tone = useLogoTone(src) + const padCls = + tone === 'light' + ? 'bg-zinc-950/95 ring-white/15 shadow-md' + : 'bg-white/95 ring-black/10 shadow-sm' + + // Vertical padding scales subtly with image height + const vPad = Math.max(4, Math.round(height * 0.4)) + const hPad = Math.max(8, Math.round(height * 0.8)) + + return ( + + {alt} + + ) +} diff --git a/src/components/ui/BrandRow.tsx b/src/components/ui/BrandRow.tsx new file mode 100644 index 0000000..60a1039 --- /dev/null +++ b/src/components/ui/BrandRow.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react' +import { useTmdbDiscoverMovies, useTmdbDiscoverTv } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import ContentRow from './ContentRow' + +interface Props { + /** TMDB company id (for movie studios) or network id (for TV networks). */ + brandId: number + label: string + subtitle?: string + /** Decides which discover endpoint to hit. */ + kind: 'movie' | 'tv' +} + +/** + * One row sourced from TMDB discover with a single `with_companies` / + * `with_networks` filter. Sorted by popularity descending so the row + * leads with the brand's most recognisable titles. + */ +export default function BrandRow({ brandId, label, subtitle, kind }: Props) { + const movieParams = kind === 'movie' ? { with_companies: String(brandId), sort_by: 'popularity.desc' } : ({} as Record) + const tvParams = kind === 'tv' ? { with_networks: String(brandId), sort_by: 'popularity.desc' } : ({} as Record) + + const movieDiscover = useTmdbDiscoverMovies(movieParams) + const tvDiscover = useTmdbDiscoverTv(tvParams) + const libraryByTmdbId = useLibraryByTmdbId() + + const items = useMemo(() => { + const raw = kind === 'movie' + ? (movieDiscover.data?.results || []).map(m => ({ ...m, media_type: 'movie' })) + : (tvDiscover.data?.results || []).map(m => ({ ...m, media_type: 'tv' })) + return mapTmdbToJf(raw.slice(0, 18), libraryByTmdbId.data) + }, [kind, movieDiscover.data, tvDiscover.data, libraryByTmdbId.data]) + + if (items.length === 0) return null + return ( + + ) +} diff --git a/src/components/ui/CanonListRow.tsx b/src/components/ui/CanonListRow.tsx new file mode 100644 index 0000000..f41b3af --- /dev/null +++ b/src/components/ui/CanonListRow.tsx @@ -0,0 +1,92 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useCanonListResolved } from '../../hooks/use-canon-list' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import type { CanonList } from '../../lib/canon-lists' +import ContentRow from './ContentRow' + +interface Props { + list: CanonList +} + +/** + * Renders one bundled canon list as a row. Lazy-mounts so we don't fire + * 20+ TMDB requests on home page load - each row only resolves once + * scrolled near the viewport. The user's library is cross-referenced + * so in-library entries get the accent ring naturally and missing + * entries are visible as the row's main story. + */ +export default function CanonListRow({ list }: Props) { + const containerRef = useRef(null) + const [near, setNear] = useState(false) + + useEffect(() => { + if (!containerRef.current) return + const el = containerRef.current + const obs = new IntersectionObserver( + entries => { + for (const e of entries) { + if (e.isIntersecting) { + setNear(true) + obs.disconnect() + return + } + } + }, + { rootMargin: '200px' }, + ) + obs.observe(el) + return () => obs.disconnect() + }, []) + + return ( +
+ {near ? : } +
+ ) +} + +// Cap the resolved list at 12 entries so each canon row triggers at +// most ~12 TMDB lookups on first scroll-into-view. Anyone curious about +// the rest can follow the source link in the row subtitle. +const MAX_CANON_RESOLVES = 12 + +function CanonListRowMounted({ list }: Props) { + const ids = useMemo( + () => list.tmdbMovieIds.slice(0, MAX_CANON_RESOLVES), + [list.tmdbMovieIds], + ) + const { items: tmdbItems } = useCanonListResolved(ids) + const libraryByTmdbId = useLibraryByTmdbId() + const items = useMemo( + () => mapTmdbToJf(tmdbItems, libraryByTmdbId.data), + [tmdbItems, libraryByTmdbId.data], + ) + if (items.length === 0) return + return ( + + ) +} + +function RowPlaceholder({ list }: Props) { + return ( +
+
+

{list.title}

+

{list.subtitle}

+
+
+ {Array.from({ length: 8 }).map((_, j) => ( +
+
+
+ ))} +
+
+ ) +} diff --git a/src/components/ui/Chip.tsx b/src/components/ui/Chip.tsx new file mode 100644 index 0000000..ba50476 --- /dev/null +++ b/src/components/ui/Chip.tsx @@ -0,0 +1,79 @@ +import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react' + +type ChipTone = 'neutral' | 'accent' | 'cool' | 'success' | 'warning' | 'error' | 'glow' | 'outline' +type ChipSize = 'xs' | 'sm' | 'md' + +const toneClass: Record = { + neutral: 'bg-white/8 text-text-1 border-white/8 hover:bg-white/12', + accent: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20', + cool: 'bg-cool/12 text-cool border-cool/25', + success: 'bg-success/12 text-success border-success/25', + warning: 'bg-warning/12 text-warning border-warning/25', + error: 'bg-error/12 text-error border-error/25', + glow: + 'bg-gradient-to-r from-accent/20 to-cool/15 text-text-1 border-accent/30 shadow-[0_0_12px_rgba(245,182,66,0.25)]', + outline: 'bg-transparent text-text-2 border-border hover:border-border-hover hover:text-text-1', +} + +const sizeClass: Record = { + xs: 'h-5 px-1.5 text-[10px] gap-1 rounded', + sm: 'h-6 px-2 text-[11px] gap-1.5 rounded-md', + md: 'h-7 px-2.5 text-[12px] gap-1.5 rounded-md', +} + +interface ChipBase { + tone?: ChipTone + size?: ChipSize + icon?: ReactNode + trailing?: ReactNode + active?: boolean +} + +export type ChipProps = ChipBase & HTMLAttributes & { as?: 'span' } +export type ChipButtonProps = ChipBase & ButtonHTMLAttributes & { as: 'button' } + +export const Chip = forwardRef( + function Chip(props, ref) { + const { + tone = 'neutral', + size = 'sm', + icon, + trailing, + active, + className = '', + children, + as = 'span', + ...rest + } = props as ChipBase & { as?: 'span' | 'button' } & Record + + const baseCls = + `inline-flex items-center font-medium tracking-tight border whitespace-nowrap select-none transition-colors duration-150 ${ + sizeClass[size] + } ${toneClass[active ? 'accent' : tone]} ${className}` + + if (as === 'button') { + return ( + + ) + } + return ( + )} + ref={ref as React.Ref} + className={baseCls} + > + {icon} + {children} + {trailing} + + ) + }, +) diff --git a/src/components/ui/ContentRow.tsx b/src/components/ui/ContentRow.tsx new file mode 100644 index 0000000..09f504c --- /dev/null +++ b/src/components/ui/ContentRow.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import { ChevronLeft, ChevronRight, ArrowRight, Library, List, ListDetails } from '../../lib/icons' +import { motion, AnimatePresence } from 'framer-motion' +import type { BaseItemDto } from '../../api/types' +import PosterCard from './PosterCard' +import { useNavigate } from 'react-router-dom' +import { getBestImage, getStoredServerUrl } from '../../api/jellyfin' +import { useQuickLookStore } from '../../stores/quick-look-store' +import { formatRuntime } from '../../lib/format' + +type Layout = 'poster' | 'backdrop' | 'list' + +interface Props { + title: string + subtitle?: string + items: BaseItemDto[] + aspect?: 'poster' | 'landscape' | 'square' + loading?: boolean + seeAllHref?: string + /** Persist a non-default layout under this key. Defaults to title. */ + layoutKey?: string +} + +function readSavedLayout(key: string, fallback: Layout): Layout { + if (typeof window === 'undefined') return fallback + const v = localStorage.getItem(`row_layout:${key}`) + if (v === 'poster' || v === 'backdrop' || v === 'list') return v + return fallback +} + +function SkeletonCards({ + count = 8, + aspect = 'poster', + width, +}: { + count?: number + aspect?: 'poster' | 'landscape' | 'square' + width: string +}) { + const aspectClass = aspect === 'poster' ? 'aspect-[2/3]' : aspect === 'square' ? 'aspect-square' : 'aspect-video' + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} + + ) +} + +export default function ContentRow({ title, subtitle, items, aspect = 'poster', loading, seeAllHref, layoutKey }: Props) { + // Map the legacy `aspect` prop onto the new `layout` model. Landscape + // rows default to 'backdrop' so existing call sites keep their look. + const defaultLayout: Layout = aspect === 'landscape' ? 'backdrop' : 'poster' + const persistKey = layoutKey || title + const [layout, setLayoutState] = useState(() => readSavedLayout(persistKey, defaultLayout)) + const setLayout = (next: Layout) => { + setLayoutState(next) + try { localStorage.setItem(`row_layout:${persistKey}`, next) } catch { /* noop */ } + } + const effectiveAspect: 'poster' | 'landscape' | 'square' = + layout === 'backdrop' ? 'landscape' : layout === 'poster' ? (aspect === 'square' ? 'square' : 'poster') : 'poster' + + const [ref, embla] = useEmblaCarousel({ + align: 'start', + containScroll: 'trimSnaps', + dragFree: true, + }) + const [canScrollPrev, setCanScrollPrev] = useState(false) + const [canScrollNext, setCanScrollNext] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + if (!embla) return + const update = () => { + setCanScrollPrev(embla.canScrollPrev()) + setCanScrollNext(embla.canScrollNext()) + } + embla.on('select', update) + embla.on('reInit', update) + embla.on('scroll', update) + update() + return () => { + embla.off('select', update) + embla.off('reInit', update) + embla.off('scroll', update) + } + }, [embla]) + + const widthClass = + effectiveAspect === 'poster' ? 'w-[160px]' + : effectiveAspect === 'square' ? 'w-[180px]' + : 'w-[280px]' + + const showHeader = !!title || !!subtitle || !!seeAllHref + + return ( +
+ {/* Row header */} + {showHeader && ( +
+
+ {title &&

{title}

} + {subtitle && ( +

{subtitle}

+ )} +
+
+ + {seeAllHref && ( + + )} +
+
+ )} + + {/* List layout - vertical stack, no carousel */} + {layout === 'list' && !loading && ( +
+ {items.slice(0, 12).map((item, i) => ( + + ))} + {items.length > 12 && ( + + )} +
+ )} + {layout !== 'list' && ( + <> + + {/* Carousel with edge fades + nav arrows */} +
+ {/* Edge fade left */} +
+ {/* Edge fade right */} +
+ + {/* Prev */} + + {canScrollPrev && ( + embla?.scrollPrev()} + className="absolute left-2 top-0 bottom-12 w-12 z-20 flex items-center justify-center opacity-0 group-hover/row:opacity-100 transition-opacity duration-200 focus:opacity-100" + aria-label="Previous" + > +
+ +
+
+ )} +
+ + {/* Next */} + + {canScrollNext && ( + embla?.scrollNext()} + className="absolute right-2 top-0 bottom-12 w-12 z-20 flex items-center justify-center opacity-0 group-hover/row:opacity-100 transition-opacity duration-200 focus:opacity-100" + aria-label="Next" + > +
+ +
+
+ )} +
+ + {/* Track */} +
+
+ {loading ? ( + + ) : ( + items.map((item, i) => ( + + item.Id && navigate(`/item/${item.Id}`)} + /> + + )) + )} +
+
+
+ + )} +
+ ) +} + +function LayoutSwitcher({ value, onChange }: { value: Layout; onChange: (l: Layout) => void }) { + const opts: Array<{ k: Layout; label: string; Icon: typeof List }> = [ + { k: 'poster', label: 'Posters', Icon: ListDetails }, + { k: 'backdrop', label: 'Backdrops', Icon: Library }, + { k: 'list', label: 'List', Icon: List }, + ] + return ( +
+ {opts.map(o => { + const on = value === o.k + return ( + + ) + })} +
+ ) +} + +function ListRowCard({ item, index }: { item: BaseItemDto; index: number }) { + const navigate = useNavigate() + const openQuickLook = useQuickLookStore(s => s.open) + const serverUrl = getStoredServerUrl() + const thumb = + getBestImage(serverUrl, item, 'thumb', 240) || + getBestImage(serverUrl, item, 'primary', 200) || + (item as any)._tmdbPoster || + null + const subtitle = [ + item.ProductionYear, + item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : '', + (item.Genres || []).slice(0, 2).join(', '), + ].filter(Boolean).join(' · ') + return ( + item.Id && navigate(`/item/${item.Id}`)} + onContextMenu={e => { e.preventDefault(); openQuickLook(item) }} + className="w-full flex items-center gap-3 p-2 rounded-lg bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition text-left focus-ring" + > +
+ {thumb && } +
+
+

+ {item.Name || 'Untitled'} +

+ {subtitle && ( +

{subtitle}

+ )} + {item.Overview && ( +

{item.Overview}

+ )} +
+
+ ) +} diff --git a/src/components/ui/ErrorBoundary.tsx b/src/components/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..dbda497 --- /dev/null +++ b/src/components/ui/ErrorBoundary.tsx @@ -0,0 +1,53 @@ +import { Component, type ReactNode } from 'react' +import { AlertCircle, RotateCw } from '../../lib/icons' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback + + return ( +
+
+
+
+ +
+
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred while rendering this view.'} +

+ +
+ ) + } + + return this.props.children + } +} diff --git a/src/components/ui/HorizontalScroller.tsx b/src/components/ui/HorizontalScroller.tsx new file mode 100644 index 0000000..11b8a28 --- /dev/null +++ b/src/components/ui/HorizontalScroller.tsx @@ -0,0 +1,122 @@ +import { useEffect, useState, type ReactNode } from 'react' +import useEmblaCarousel from 'embla-carousel-react' +import { motion, AnimatePresence } from 'framer-motion' +import { ChevronLeft, ChevronRight } from '../../lib/icons' + +interface Props { + children: ReactNode + /** Padding-x applied to the inner track. Matches whatever sits above + * it in the page (typically `px-7`). */ + trackPadding?: string + /** Gap between children. Cast strips use 12px; recommendation rows + * use 12-16px. */ + gap?: string + /** Vertical offset for the nav arrows so they sit centered on the + * poster/avatar, not on the label below. The default `bottom-12` + * matches ContentRow which has poster + 2 lines of text. Use + * `bottom-14` for cast (avatar + 2 lines) or `bottom-0` to center + * on the full row. */ + arrowsBottomInset?: string + /** Optional className overrides on the outer wrapper. */ + className?: string +} + +/** + * Carousel chrome reused across content rows, cast strips, crew + * grids, and TMDB recommendation strips. Wraps its children in an + * Embla viewport with smooth horizontal scroll, hover-revealed nav + * arrows on either side, and edge fades that hide when the user has + * already scrolled to that end. + * + * Children should be a flex row of equal-height items (the parent + * supplies the layout; HorizontalScroller just provides the chrome). + */ +export default function HorizontalScroller({ + children, + trackPadding = 'px-7', + gap = 'gap-3', + arrowsBottomInset = 'bottom-12', + className, +}: Props) { + const [ref, embla] = useEmblaCarousel({ + align: 'start', + containScroll: 'trimSnaps', + dragFree: true, + }) + const [canPrev, setCanPrev] = useState(false) + const [canNext, setCanNext] = useState(false) + + useEffect(() => { + if (!embla) return + const update = () => { + setCanPrev(embla.canScrollPrev()) + setCanNext(embla.canScrollNext()) + } + embla.on('select', update) + embla.on('reInit', update) + embla.on('scroll', update) + update() + return () => { + embla.off('select', update) + embla.off('reInit', update) + embla.off('scroll', update) + } + }, [embla]) + + return ( +
+ {/* Edge fade left */} +
+ {/* Edge fade right */} +
+ + + {canPrev && ( + embla?.scrollPrev()} + className={`absolute left-2 top-0 ${arrowsBottomInset} w-12 z-20 flex items-center justify-center opacity-0 group-hover/scroller:opacity-100 transition-opacity duration-200 focus:opacity-100`} + aria-label="Previous" + > +
+ +
+
+ )} +
+ + + {canNext && ( + embla?.scrollNext()} + className={`absolute right-2 top-0 ${arrowsBottomInset} w-12 z-20 flex items-center justify-center opacity-0 group-hover/scroller:opacity-100 transition-opacity duration-200 focus:opacity-100`} + aria-label="Next" + > +
+ +
+
+ )} +
+ +
+
{children}
+
+
+ ) +} diff --git a/src/components/ui/LazyMount.tsx b/src/components/ui/LazyMount.tsx new file mode 100644 index 0000000..1fa3fe0 --- /dev/null +++ b/src/components/ui/LazyMount.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef, useState, Component, type ReactNode } from 'react' + +interface Props { + /** Children to mount when scrolled near, unmount when scrolled far. + * React Query's cache keeps data warm between mounts, so re-entering + * the area doesn't re-fetch (within the query's staleTime). */ + children: ReactNode + /** Optional placeholder rendered while children are unmounted. + * Default: a skeleton row sized to match a typical ContentRow so + * the page's scroll geometry stays stable when content swaps in + * and out. */ + placeholder?: ReactNode + /** Mount when the wrapper enters this margin around the viewport. + * Should be tighter than `unmountMargin` so the user doesn't see + * empty content as they scroll towards the row. */ + mountMargin?: string + /** Unmount when the wrapper leaves this (larger) margin. The gap + * between mount + unmount margins gives hysteresis - the row stays + * mounted while the user is still scrolling around the visible + * area, and only tears down once they've moved well past it. */ + unmountMargin?: string + /** Optional override on the wrapper className. */ + className?: string +} + +/** + * Windowed mount/unmount for expensive children (data fetches, large + * image grids, motion trees). Used by HomePage to keep memory bounded + * during a long scroll - rows mount when you approach them, stay + * mounted while you're nearby, and unmount once you've scrolled well + * past them. + * + * Two IntersectionObservers with different root margins provide the + * hysteresis: a tight mount margin (~600px) so content's ready by the + * time it scrolls into view, and a wider unmount margin (~1500px) so + * the row doesn't thrash when the user scrolls back and forth across + * the boundary. + * + * Directional unmount: only rows BELOW the viewport are allowed to + * unmount. Anything above the viewport stays mounted so the layout + * above the user's scroll position can't shrink (which would jump the + * scroll). In practice this means scrolling down keeps everything + * you've passed alive, and scrolling back up unmounts the rows you've + * left behind underneath. + */ +export default function LazyMount({ + children, + placeholder, + mountMargin = '600px', + unmountMargin = '1500px', + className, +}: Props) { + const ref = useRef(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + if (typeof IntersectionObserver === 'undefined') { + // SSR / very old browsers - render immediately so the page + // doesn't ship blank skeletons forever. + setMounted(true) + return + } + const el = ref.current + if (!el) return + + // Mount observer: when the wrapper is within mountMargin of the + // viewport, become mounted. Stays attached so we re-mount if the + // user scrolls back into range. + const mountObs = new IntersectionObserver( + entries => { + for (const e of entries) { + if (e.isIntersecting) setMounted(true) + } + }, + { rootMargin: mountMargin }, + ) + // Unmount observer: only fires when the row is far OUTSIDE the + // wider unmountMargin AND the row is currently below the viewport + // (boundingClientRect.top > 0 means its top edge is below the + // viewport's top). Rows above the viewport never unmount, because + // shrinking layout above the user's scroll position would yank the + // scroll position upward. + const unmountObs = new IntersectionObserver( + entries => { + for (const e of entries) { + if (!e.isIntersecting && e.boundingClientRect.top > 0) { + setMounted(false) + } + } + }, + { rootMargin: unmountMargin }, + ) + mountObs.observe(el) + unmountObs.observe(el) + return () => { + mountObs.disconnect() + unmountObs.disconnect() + } + }, [mountMargin, unmountMargin]) + + return ( +
+ {mounted ? {children} : (placeholder ?? )} +
+ ) +} + +/** + * Per-row error boundary that isolates a single home-page row from the rest + * of the page. If a row crashes (bad data, a missing field, a thrown render), + * only that row goes blank - sibling rows keep rendering. Failures are sent + * to the console for diagnosis but never surfaced as a user-visible error + * panel inside the row, since a silent gap is far less disruptive than a + * red banner in the middle of the home feed. + */ +export class RowBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false } + static getDerivedStateFromError() { + return { failed: true } + } + componentDidCatch(error: unknown) { + console.warn('[home row] render failed', error) + } + render() { + if (this.state.failed) return null + return this.props.children + } +} + +function DefaultRowSkeleton() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, j) => ( +
+
+
+ ))} +
+
+ ) +} diff --git a/src/components/ui/LetterboxdAddModal.tsx b/src/components/ui/LetterboxdAddModal.tsx new file mode 100644 index 0000000..e6246f6 --- /dev/null +++ b/src/components/ui/LetterboxdAddModal.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X } from '../../lib/icons' +import { normaliseLetterboxdListUrl } from '../../api/letterboxd' +import { useLetterboxdLists } from '../../stores/letterboxd-lists-store' + +interface Props { + open: boolean + onClose: () => void +} + +export default function LetterboxdAddModal({ open, onClose }: Props) { + const add = useLetterboxdLists(s => s.add) + const [url, setUrl] = useState('') + const [error, setError] = useState(null) + + function save() { + const norm = normaliseLetterboxdListUrl(url) + if (!norm) { + setError('That doesn\'t look like a Letterboxd list URL.') + return + } + add(norm) + setUrl('') + setError(null) + onClose() + } + + return ( + + {open && ( + + e.stopPropagation()} + className="relative w-full max-w-md rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] p-5" + > + +

+ Add a Letterboxd list +

+

+ Paste the URL of any public Letterboxd list. We'll cross-check it against + your library so missing entries are obvious. +

+ { + setUrl(e.target.value) + if (error) setError(null) + }} + onKeyDown={e => { if (e.key === 'Enter') save() }} + placeholder="https://letterboxd.com/USER/list/SLUG/" + autoFocus + className="w-full h-10 px-3 rounded-md bg-elevated/60 ring-1 ring-border focus:ring-accent/50 outline-none text-[13px] tracking-tight" + /> + {error && ( +

{error}

+ )} +
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/components/ui/LetterboxdListRow.tsx b/src/components/ui/LetterboxdListRow.tsx new file mode 100644 index 0000000..382e62a --- /dev/null +++ b/src/components/ui/LetterboxdListRow.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useQueries } from '@tanstack/react-query' +import { Trash2 } from '../../lib/icons' +import { fetchLetterboxdList, type LetterboxdListItem } from '../../api/letterboxd' +import { getMovieFull } from '../../api/tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { useLetterboxdLists, type SavedLetterboxdList } from '../../stores/letterboxd-lists-store' +import ContentRow from './ContentRow' + +interface Props { + saved: SavedLetterboxdList +} + +const RSS_STALE = 6 * 60 * 60 * 1000 + +/** + * One imported Letterboxd list rendered as a content row. Lazy-mounts + * once scrolled near the viewport so importing 5 lists doesn't burn + * 100+ TMDB requests on home page load. + */ +export default function LetterboxdListRow({ saved }: Props) { + const containerRef = useRef(null) + const [near, setNear] = useState(false) + + useEffect(() => { + if (!containerRef.current) return + const el = containerRef.current + const obs = new IntersectionObserver( + entries => { + for (const e of entries) { + if (e.isIntersecting) { + setNear(true) + obs.disconnect() + return + } + } + }, + { rootMargin: '200px' }, + ) + obs.observe(el) + return () => obs.disconnect() + }, []) + + return ( +
+ {near ? : } +
+ ) +} + +function Mounted({ saved }: Props) { + const remove = useLetterboxdLists(s => s.remove) + const rss = useQuery({ + queryKey: ['letterboxd', 'list', saved.url], + queryFn: () => fetchLetterboxdList(saved.url), + staleTime: RSS_STALE, + }) + const data = rss.data + // Cap at 30 entries to keep the request burst reasonable; serious + // lists with 100 films would slam TMDB on first mount otherwise. + const entries: LetterboxdListItem[] = useMemo( + () => (data?.items || []).slice(0, 30), + [data?.items], + ) + const ids = useMemo( + () => entries.map(e => e.tmdbId).filter((x): x is string => !!x).map(Number), + [entries], + ) + const tmdbQueries = useQueries({ + queries: ids.map(id => ({ + queryKey: ['tmdb', 'movie-full', id], + queryFn: () => getMovieFull(id), + staleTime: 24 * 60 * 60 * 1000, + })), + }) + const libraryByTmdbId = useLibraryByTmdbId() + const items = useMemo(() => { + const tmdbMap = new Map() + ids.forEach((id, i) => { + const d = tmdbQueries[i]?.data + if (d) tmdbMap.set(id, { ...d, media_type: 'movie' }) + }) + // Preserve list order; drop items the proxy returned without a TMDB id. + const ordered: any[] = [] + for (const e of entries) { + if (!e.tmdbId) continue + const d = tmdbMap.get(Number(e.tmdbId)) + if (d) ordered.push(d) + } + return mapTmdbToJf(ordered, libraryByTmdbId.data) + }, [entries, ids, tmdbQueries, libraryByTmdbId.data]) + + if (rss.isLoading) return + if (!data) { + return ( +
+

+ {saved.customTitle || 'Letterboxd list'} +

+

+ Couldn't load this list. The proxy may be down, or the URL is private. +

+ +
+ ) + } + if (items.length === 0) return null + + const matched = items.filter(i => (i as any)._inLibrary).length + const subtitle = `${matched} of ${items.length} in your library · from Letterboxd` + + return ( +
+ + +
+ ) +} + +function Placeholder({ saved }: Props) { + return ( +
+
+

+ {saved.customTitle || 'Letterboxd list'} +

+

{saved.url}

+
+
+ {Array.from({ length: 6 }).map((_, j) => ( +
+
+
+ ))} +
+
+ ) +} diff --git a/src/components/ui/MediaTechIcons.tsx b/src/components/ui/MediaTechIcons.tsx new file mode 100644 index 0000000..e9e6a5e --- /dev/null +++ b/src/components/ui/MediaTechIcons.tsx @@ -0,0 +1,416 @@ +import { useEffect, useState } from 'react' +import type { BaseItemDto } from '../../api/types' +import { + pickPrimarySource, + getAudioStreams, + getSubtitleStreams, + resolutionLabel, + videoRangeLabel, +} from '../../lib/jellyfin-meta' + +/** + * Compact horizontal strip of "official-style" media tech badges. + * Each badge tries to render an SVG from /icons/media-tech/.svg + * (drop your own per public/icons/media-tech/README.md) and falls back to + * a clean typographic badge when the SVG is missing. + */ + +interface Props { + item: BaseItemDto | null | undefined + size?: 'sm' | 'md' + className?: string +} + +const ICON_BASE = '/icons/media-tech' + +export default function MediaTechIcons({ item, size = 'md', className = '' }: Props) { + if (!item) return null + const source = pickPrimarySource(item) + const audio = getAudioStreams(item)[0] + const subs = getSubtitleStreams(item) + + const res = resolutionLabel(item) + const range = videoRangeLabel(item) + + const audioCodec = (audio?.Codec || '').toLowerCase() + const audioProfile = (audio?.Profile || '').toLowerCase() + const audioSpatial = (audio?.AudioSpatialFormat || '').toLowerCase() + const channels = audio?.Channels || 0 + const channelLayout = (audio?.ChannelLayout || '').toLowerCase() + + const audio_ = pickAudioFormat(audioCodec, audioProfile, audioSpatial) + const channel_ = pickChannelLabel(channels, channelLayout) + const container = source?.Container?.toLowerCase() + + const anything = res || range || audio_ || channel_ || container || subs.length > 0 + if (!anything) return null + + return ( +
+ {res && } + {range && } + {audio_ && } + {channel_ && } + {container && } + {subs.length > 0 && } +
+ ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Smart icon-or-fallback */ +/* ──────────────────────────────────────────────────────────── */ + +/** + * Reads a local SVG once, parses its viewBox / dimensions to derive aspect + * ratio, and caches the result. We then render the icon via CSS mask-image - + * which paints the element's `background-color` clipped by the SVG silhouette, + * giving a uniform white render regardless of the source SVG's fill colors. + */ +const svgInfoCache = new Map() + +function useSvgInfo(url: string): { aspect: number | null; missing: boolean } { + const initial = svgInfoCache.get(url) + const [aspect, setAspect] = useState( + initial && initial !== 'missing' ? initial.aspect : null, + ) + const [missing, setMissing] = useState(initial === 'missing') + + useEffect(() => { + if (svgInfoCache.has(url)) return + let cancelled = false + + fetch(url) + .then(res => { + if (!res.ok) throw new Error(`${res.status}`) + const ct = res.headers.get('content-type') || '' + if (!ct.includes('svg') && !ct.includes('xml')) throw new Error('not svg') + return res.text() + }) + .then(raw => { + const a = parseAspect(raw) + if (a == null) throw new Error('no dimensions') + svgInfoCache.set(url, { aspect: a }) + if (!cancelled) setAspect(a) + }) + .catch(() => { + svgInfoCache.set(url, 'missing') + if (!cancelled) setMissing(true) + }) + + return () => { cancelled = true } + }, [url]) + + return { aspect, missing } +} + +function parseAspect(svg: string): number | null { + const vb = svg.match(/]*\sviewBox\s*=\s*"\s*([-\d.eE]+)\s+([-\d.eE]+)\s+([-\d.eE]+)\s+([-\d.eE]+)\s*"/i) + if (vb) { + const w = parseFloat(vb[3]) + const h = parseFloat(vb[4]) + if (w > 0 && h > 0) return w / h + } + const wAttr = svg.match(/]*\swidth\s*=\s*"([\d.]+)/i) + const hAttr = svg.match(/]*\sheight\s*=\s*"([\d.]+)/i) + if (wAttr && hAttr) { + const w = parseFloat(wAttr[1]) + const h = parseFloat(hAttr[1]) + if (w > 0 && h > 0) return w / h + } + return null +} + +function IconOrFallback({ + iconFile, + fallback, + size, + alt, +}: { + iconFile: string + fallback: React.ReactNode + size: 'sm' | 'md' + alt: string +}) { + const url = `${ICON_BASE}/${iconFile}` + const { aspect, missing } = useSvgInfo(url) + + if (missing) return <>{fallback} + + const h = size === 'sm' ? 18 : 22 + const w = aspect ? Math.round(h * aspect) : h * 3 + // Cap absurdly wide logos so a single badge can't dominate the row + const cappedWidth = Math.min(w, h * 6) + + return ( + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Resolution */ +/* ──────────────────────────────────────────────────────────── */ + +function ResolutionBadge({ res, size }: { res: string; size: 'sm' | 'md' }) { + const file = + res === '4K' ? '4k-uhd.svg' + : res === '1080p' ? 'hd-1080p.svg' + : res === '720p' ? 'hd-720p.svg' + : null + + if (file) { + return } /> + } + return +} + +function ResolutionTextBadge({ res, size }: { res: string; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + if (res === '4K') { + return ( + + 4K + UHD + + ) + } + if (res === '1080p') { + return ( + + HD + 1080P + + ) + } + return ( + + {res} + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* HDR / Dolby Vision */ +/* ──────────────────────────────────────────────────────────── */ + +function RangeBadge({ range, size }: { range: string; size: 'sm' | 'md' }) { + const file = + range === 'Dolby Vision' ? 'dolby-vision.svg' + : range === 'HDR10+' ? 'hdr10-plus.svg' + : range === 'HDR10' || range === 'HDR' ? 'hdr10.svg' + : null + + if (file) { + return } /> + } + return +} + +function RangeTextBadge({ range, size }: { range: string; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + if (range === 'Dolby Vision') { + return ( + + Dolby Vision + + ) + } + if (range === 'HDR10+') { + return ( + + HDR10+ + + ) + } + return ( + + {range} + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Audio */ +/* ──────────────────────────────────────────────────────────── */ + +interface AudioFormat { + iconFile: string | null + label: string + alt: string + /** When set, the typographic fallback gets a tone treatment. */ + fallbackTone?: 'dolby' | 'dts' | 'lossless' | 'lossy' +} + +function AudioBadge({ format, size }: { format: AudioFormat; size: 'sm' | 'md' }) { + if (format.iconFile) { + return ( + } + /> + ) + } + return +} + +function AudioTextBadge({ format, size }: { format: AudioFormat; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + const toneCls = + format.fallbackTone === 'dolby' + ? 'bg-black text-white ring-white/15' + : format.fallbackTone === 'dts' + ? 'bg-gradient-to-b from-zinc-700 to-black text-white ring-white/15' + : format.fallbackTone === 'lossless' + ? 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' + : 'bg-white/8 text-white/85 ring-white/12' + return ( + + {format.label} + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Channel / Container / Subs */ +/* ──────────────────────────────────────────────────────────── */ + +function ChannelBadge({ label, size }: { label: string; size: 'sm' | 'md' }) { + const file = + label === '5.1' ? '5-1.svg' + : label === '7.1' ? '7-1.svg' + : label === 'Stereo' ? 'stereo.svg' + : label === 'Mono' ? 'mono.svg' + : null + + if (file) { + return } /> + } + return +} + +function ChannelTextBadge({ label, size }: { label: string; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + return ( + + {label} + + ) +} + +function ContainerBadge({ label, size }: { label: string; size: 'sm' | 'md' }) { + // Allow per-container icon files: mkv.svg, mp4.svg, webm.svg, etc. + const file = `${label}.svg` + const upper = label.toUpperCase() + return ( + } + /> + ) +} + +function ContainerTextBadge({ label, size }: { label: string; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + return ( + + {label} + + ) +} + +function SubBadge({ count, size }: { count: number; size: 'sm' | 'md' }) { + return ( + } + /> + ) +} + +function SubTextBadge({ count, size }: { count: number; size: 'sm' | 'md' }) { + const h = size === 'sm' ? 'h-5' : 'h-6' + const fs = size === 'sm' ? 'text-[9px]' : 'text-[10px]' + return ( + + CC + ×{count} + + ) +} + +/* ──────────────────────────────────────────────────────────── */ +/* Codec → format mapping */ +/* ──────────────────────────────────────────────────────────── */ + +function pickAudioFormat(codec: string, profile: string, spatial: string): AudioFormat | null { + if (spatial.includes('dolbyatmos') || profile.includes('atmos')) { + return { iconFile: 'dolby-atmos.svg', label: 'Atmos', alt: 'Dolby Atmos', fallbackTone: 'dolby' } + } + if (spatial.includes('dtsx') || profile.includes(':x')) { + return { iconFile: 'dts-x.svg', label: 'DTS:X', alt: 'DTS:X', fallbackTone: 'dts' } + } + if (codec === 'eac3') return { iconFile: 'dolby-digital-plus.svg', label: 'Dolby Digital+', alt: 'Dolby Digital Plus', fallbackTone: 'dolby' } + if (codec === 'truehd') return { iconFile: 'dolby-truehd.svg', label: 'TrueHD', alt: 'Dolby TrueHD', fallbackTone: 'dolby' } + if (codec === 'ac3') return { iconFile: 'dolby-digital.svg', label: 'Dolby Digital', alt: 'Dolby Digital', fallbackTone: 'dolby' } + + if (codec === 'dts') { + if (profile.includes('hd ma') || profile.includes('hd-ma')) { + return { iconFile: 'dts-hd-ma.svg', label: 'DTS-HD MA', alt: 'DTS-HD Master Audio', fallbackTone: 'dts' } + } + if (profile.includes('hd hra') || profile.includes('hd-hra')) { + return { iconFile: 'dts-hd-hra.svg', label: 'DTS-HD HRA', alt: 'DTS-HD HRA', fallbackTone: 'dts' } + } + return { iconFile: 'dts.svg', label: 'DTS', alt: 'DTS', fallbackTone: 'dts' } + } + + if (codec === 'flac') return { iconFile: null, label: 'FLAC', alt: 'FLAC', fallbackTone: 'lossless' } + if (codec === 'pcm' || codec.startsWith('pcm_')) return { iconFile: null, label: 'PCM', alt: 'PCM', fallbackTone: 'lossless' } + if (codec === 'alac') return { iconFile: null, label: 'ALAC', alt: 'ALAC', fallbackTone: 'lossless' } + if (codec === 'opus') return { iconFile: null, label: 'Opus', alt: 'Opus', fallbackTone: 'lossy' } + if (codec === 'aac') return { iconFile: null, label: 'AAC', alt: 'AAC', fallbackTone: 'lossy' } + if (codec === 'mp3') return { iconFile: null, label: 'MP3', alt: 'MP3', fallbackTone: 'lossy' } + if (codec === 'vorbis') return { iconFile: null, label: 'Vorbis', alt: 'Vorbis', fallbackTone: 'lossy' } + + return null +} + +function pickChannelLabel(channels: number, layout: string): string | null { + if (layout.includes('7.1') || channels >= 8) return '7.1' + if (layout.includes('5.1') || channels === 6) return '5.1' + if (layout.includes('quad') || channels === 4) return '4.0' + if (channels === 2) return 'Stereo' + if (channels === 1) return 'Mono' + return null +} diff --git a/src/components/ui/MetaBadges.tsx b/src/components/ui/MetaBadges.tsx new file mode 100644 index 0000000..8ba6621 --- /dev/null +++ b/src/components/ui/MetaBadges.tsx @@ -0,0 +1,84 @@ +import type { BaseItemDto } from '../../api/types' +import { getTopBadges, getAllBadges } from '../../lib/jellyfin-meta' + +interface Props { + item: Pick + variant?: 'card' | 'hero' + className?: string +} + +/** Tiny corner pills for poster cards (4K / HDR / DV / Atmos). Hidden when nothing notable. */ +export default function MetaBadges({ item, variant = 'card', className = '' }: Props) { + const badges = variant === 'card' ? getTopBadges(item) : getAllBadges(item) + if (badges.length === 0) return null + + if (variant === 'card') { + return ( +
+ {badges.map(b => ( + + ))} +
+ ) + } + + return ( +
+ {badges.map(b => ( + + ))} +
+ ) +} + +function CardBadge({ label }: { label: string }) { + const tone = badgeTone(label) + return ( + + {label} + + ) +} + +function HeroBadge({ label }: { label: string }) { + const tone = badgeTone(label, true) + return ( + + {label} + + ) +} + +function badgeTone(label: string, hero = false): string { + // Subtle gradient accents to reinforce visual meaning without screaming + switch (label) { + case '4K': + return hero + ? 'bg-cool/20 text-cool border-cool/35' + : 'bg-cool/30 text-cool border-cool/40' + case 'HDR10': + case 'HDR10+': + case 'HDR': + return hero + ? 'bg-gradient-to-r from-amber-500/30 to-rose-500/25 text-amber-200 border-amber-400/35' + : 'bg-gradient-to-r from-amber-500/40 to-rose-500/35 text-amber-100 border-amber-400/45' + case 'Dolby Vision': + return hero + ? 'bg-gradient-to-r from-indigo-500/30 to-blue-500/25 text-indigo-100 border-indigo-400/40' + : 'bg-gradient-to-r from-indigo-500/40 to-blue-500/35 text-indigo-100 border-indigo-400/50' + case 'Atmos': + case 'DTS:X': + return hero + ? 'bg-emerald-500/20 text-emerald-200 border-emerald-500/35' + : 'bg-emerald-500/30 text-emerald-100 border-emerald-500/40' + default: + return hero + ? 'bg-white/10 text-white/85 border-white/15' + : 'bg-white/15 text-white/95 border-white/20' + } +} diff --git a/src/components/ui/OfflineBanner.tsx b/src/components/ui/OfflineBanner.tsx new file mode 100644 index 0000000..f9f5bfa --- /dev/null +++ b/src/components/ui/OfflineBanner.tsx @@ -0,0 +1,51 @@ +import { useState, useEffect } from 'react' +import { WifiOff, RefreshCw } from '../../lib/icons' +import { motion, AnimatePresence } from 'framer-motion' + +export default function OfflineBanner() { + const [offline, setOffline] = useState(!navigator.onLine) + + useEffect(() => { + const goOffline = () => setOffline(true) + const goOnline = () => setOffline(false) + window.addEventListener('offline', goOffline) + window.addEventListener('online', goOnline) + return () => { + window.removeEventListener('offline', goOffline) + window.removeEventListener('online', goOnline) + } + }, []) + + return ( + + {offline && ( + +
+
+ + + + + + Cannot reach server + - check your connection +
+ +
+
+ )} +
+ ) +} diff --git a/src/components/ui/PersonSpotlightRow.tsx b/src/components/ui/PersonSpotlightRow.tsx new file mode 100644 index 0000000..b8e3e85 --- /dev/null +++ b/src/components/ui/PersonSpotlightRow.tsx @@ -0,0 +1,129 @@ +import { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useTmdbPerson } from '../../hooks/use-tmdb' +import { useLibraryByTmdbId } from '../../hooks/use-jellyfin' +import { mapTmdbToJf } from '../../lib/tmdb-mapping' +import { getTmdbImageUrl } from '../../api/tmdb' +import ContentRow from './ContentRow' + +interface Props { + /** TMDB person id. */ + personId: number + /** Display name; pre-known from the spotlight aggregation. */ + name: string + /** "Director" or "Actor" - drives the credit filter. */ + role: 'director' | 'actor' + profilePath?: string | null + /** How many of this person's titles the user has already watched. */ + watchedCount: number +} + +const COMMERCIAL_DEPARTMENTS = ['Acting', 'Directing'] + +/** + * Renders a personal-pick row sourced from a TMDB person's filmography. + * + * Strategy: + * - Director: pull from `combined_credits.crew` filtered to job="Director", + * sorted by release date descending. + * - Actor: pull from `combined_credits.cast`, dropping low-billed + * appearances (one-line cameos), sorted by popularity then date. + * + * In-library matches show the accent ring naturally via PosterCard's + * `_inLibrary` honoring; missing items render in the row beside them. + */ +export default function PersonSpotlightRow({ personId, name, role, profilePath, watchedCount }: Props) { + const person = useTmdbPerson(personId) + const libraryByTmdbId = useLibraryByTmdbId() + + const items = useMemo(() => { + const credits = person.data?.combined_credits + if (!credits) return [] + const list = + role === 'director' + ? (credits.crew || []).filter(c => c.job === 'Director') + : (credits.cast || []) + // Drop trivia / archival appearances and ultra-deep cuts. + .filter(c => COMMERCIAL_DEPARTMENTS.includes((c as any).department || 'Acting')) + + // De-dupe by id (a director may have multiple crew credits on one film + // when they're also writer; a cast member may appear twice for episodic + // tv guest spots). + const seen = new Set() + const unique = list.filter(c => { + if (seen.has(c.id)) return false + seen.add(c.id) + return true + }) + + // Sort: newest first, but with low-popularity entries pushed to the end. + const sorted = [...unique].sort((a, b) => { + const ap = a.popularity ?? 0 + const bp = b.popularity ?? 0 + // Bucket popularity so date dominates within "famous-enough" tiers. + const ab = ap >= 5 ? 0 : 1 + const bb = bp >= 5 ? 0 : 1 + if (ab !== bb) return ab - bb + const ad = (a.release_date || a.first_air_date || '').slice(0, 10) + const bd = (b.release_date || b.first_air_date || '').slice(0, 10) + return bd.localeCompare(ad) + }) + + return mapTmdbToJf(sorted.slice(0, 24), libraryByTmdbId.data) + }, [person.data, libraryByTmdbId.data, role]) + + if (items.length === 0) return null + + const totalCount = items.length + const remaining = Math.max(0, totalCount - watchedCount) + + return ( +
+
+
+ {profilePath && ( + + )} +
+

+ {role === 'director' ? 'Director spotlight' : 'Following the actor'} +

+

+ +

+

+ {watchedCount} watched + {' · '} + {remaining} remaining +

+
+
+
+ +
+ ) +} + +function PersonName({ personId, name }: { personId: number; name: string }) { + const navigate = useNavigate() + return ( + + ) +} diff --git a/src/components/ui/PosterCard.tsx b/src/components/ui/PosterCard.tsx new file mode 100644 index 0000000..ce9bf51 --- /dev/null +++ b/src/components/ui/PosterCard.tsx @@ -0,0 +1,445 @@ +import { useEffect, useRef, useState } from 'react' +import { Play, Heart, Check, Library } from '../../lib/icons' +import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion' +import type { BaseItemDto } from '../../api/types' +import { getBestImage, getStoredServerUrl } from '../../api/jellyfin' +import MetaBadges from './MetaBadges' +import { usePreferencesStore } from '../../stores/preferences-store' +import { useHoverTrailer } from '../../hooks/use-hover-trailer' +import { useCollectionMeter } from '../../hooks/use-collection-meter' +import { usePrebuffer } from '../../hooks/use-prebuffer' +import { useQuickLookStore } from '../../stores/quick-look-store' +import AvailabilityChip from './AvailabilityChip' +import { tmdbDecoration, isInLibrary, getTmdbId } from '../../lib/item-types' + +interface Props { + item: BaseItemDto + aspect?: 'poster' | 'landscape' | 'square' + onClick?: () => void + priority?: boolean + /** Mark this card as belonging to the user's local library (TMDB id matched). */ + inLibrary?: boolean + /** Reduce visual weight for items NOT in library (in mixed grids). */ + dim?: boolean + /** Multi-select state from the library selection store. When `selected` is + * true the card renders a check overlay; the parent owns the toggle. */ + selected?: boolean + /** Called on Ctrl/Cmd-click when selection is enabled. Returns true if + * the click should be considered a selection toggle (suppressing + * navigation). */ + onToggleSelect?: () => void + /** Library context: rendered as part of the user's own library page. + * Suppresses badges that are redundant in that context (no point + * flagging "Available" or "In library" on items the user obviously + * already owns). */ + libraryContext?: boolean + /** Tighter corner radius for dense layouts (Library "map" view) where + * rounded-lg crops too much of the artwork at small card sizes. */ + compact?: boolean +} + +export default function PosterCard({ + item, + aspect = 'poster', + onClick, + priority = false, + inLibrary = false, + dim = false, + selected = false, + onToggleSelect, + libraryContext = false, + compact = false, +}: Props) { + const cardRef = useRef(null) + const showTechBadges = usePreferencesStore(s => s.showTechBadges) + const reduceMotion = usePreferencesStore(s => s.reduceMotion) + const hoverTrailersPref = usePreferencesStore(s => s.hoverTrailers) + const quickLookPref = usePreferencesStore(s => s.quickLookEnabled) + const openQuickLook = useQuickLookStore(s => s.open) + const [hovered, setHovered] = useState(false) + const [imgLoaded, setImgLoaded] = useState(false) + const [imgError, setImgError] = useState(false) + const [trailerStarted, setTrailerStarted] = useState(false) + const longPressTimer = useRef | null>(null) + const trailerUnmountTimer = useRef | null>(null) + + const trailer = useHoverTrailer(item, hovered, hoverTrailersPref && !reduceMotion) + // Same hover-arm gate as the trailer: meter only fetches once the user + // has paused on the card. Returns null when the movie isn't part of a + // TMDB collection. + const collectionMeterPref = usePreferencesStore(s => s.detail.show.collectionMeter) + const collectionMeter = useCollectionMeter(item, hovered && collectionMeterPref) + // Pre-fire PlaybackInfo + first-kilobyte fetch so the player click→play + // path feels instant. Hover-armed for the same reason as the trailer: + // we don't want to slam the server when sweeping the cursor across a + // grid. + const prebufferPref = usePreferencesStore(s => s.prebufferOnHover) + usePrebuffer(item, hovered && prebufferPref) + // Mount the trailer iframe when we have a video AND we're hovered. + // Unmount is delayed by 1.5s after leaving so a brief mouse-out + // (moving to read the title, sliding past, etc.) doesn't tear down + // the YouTube load and re-trigger autoplay on the next hover. + useEffect(() => { + if (hovered && trailer.videoKey) { + if (trailerUnmountTimer.current) { + clearTimeout(trailerUnmountTimer.current) + trailerUnmountTimer.current = null + } + if (!trailerStarted) setTrailerStarted(true) + return + } + if (!hovered && trailerStarted) { + if (trailerUnmountTimer.current) clearTimeout(trailerUnmountTimer.current) + trailerUnmountTimer.current = setTimeout(() => setTrailerStarted(false), 1500) + return () => { + if (trailerUnmountTimer.current) { + clearTimeout(trailerUnmountTimer.current) + trailerUnmountTimer.current = null + } + } + } + return undefined + }, [hovered, trailer.videoKey, trailerStarted]) + + // Magnetic tilt - flat rotation when reduce-motion is enabled + const mx = useMotionValue(0) + const my = useMotionValue(0) + const rotateX = useSpring(useTransform(my, [-0.5, 0.5], reduceMotion ? [0, 0] : [3.5, -3.5]), { stiffness: 220, damping: 22 }) + const rotateY = useSpring(useTransform(mx, [-0.5, 0.5], reduceMotion ? [0, 0] : [-3.5, 3.5]), { stiffness: 220, damping: 22 }) + const glareX = useTransform(mx, [-0.5, 0.5], ['25%', '75%']) + const glareY = useTransform(my, [-0.5, 0.5], ['25%', '75%']) + + function handleMove(e: React.MouseEvent) { + if (reduceMotion || !cardRef.current) return + const rect = cardRef.current.getBoundingClientRect() + mx.set((e.clientX - rect.left) / rect.width - 0.5) + my.set((e.clientY - rect.top) / rect.height - 0.5) + } + function handleLeave() { + setHovered(false) + // trailerStarted is reset by the delayed-unmount effect above so a + // brief cursor-out keeps the iframe alive. + if (longPressTimer.current) { + clearTimeout(longPressTimer.current) + longPressTimer.current = null + } + mx.set(0) + my.set(0) + } + + function handleContextMenu(e: React.MouseEvent) { + if (!quickLookPref) return + e.preventDefault() + openQuickLook(item) + } + function handleTouchStart() { + if (!quickLookPref) return + if (longPressTimer.current) clearTimeout(longPressTimer.current) + longPressTimer.current = setTimeout(() => openQuickLook(item), 550) + } + function handleTouchEnd() { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current) + longPressTimer.current = null + } + } + + const serverUrl = getStoredServerUrl() + + const tmdbPosterUrl = tmdbDecoration(item)._tmdbPoster + const imageUrl = + getBestImage( + serverUrl, + item, + aspect === 'landscape' ? 'thumb' : 'primary', + aspect === 'poster' ? 320 : 480, + ) || + tmdbPosterUrl || + '' + // Items can carry `_inLibrary` from a TMDB-mapped synthetic; respect it as default. + const effectiveInLibrary = inLibrary || isInLibrary(item) + + const title = item.Name || item.SeriesName || 'Untitled' + const subtitle = [ + item.ProductionYear, + item.RunTimeTicks ? `${Math.round(Number(item.RunTimeTicks) / 600000000)} min` : '', + item.Type === 'Episode' && item.ParentIndexNumber != null && item.IndexNumber != null + ? `S${item.ParentIndexNumber} - E${item.IndexNumber}` + : '', + ].filter(Boolean).join(' · ') + + const progress = item.UserData?.PlayedPercentage + const isWatched = item.UserData?.Played + const isFavorite = item.UserData?.IsFavorite + + const aspectClass = + aspect === 'poster' ? 'aspect-[2/3]' + : aspect === 'square' ? 'aspect-square' + : 'aspect-video' + + // Iframes don't honor `object-fit: cover`. We compute explicit + // cover dimensions from the card aspect AND oversize the iframe by + // ~30% in each axis so the edge chrome (top title bar, bottom + // control bar) falls outside the visible window. The video stays + // centered via translate(-50%, -50%) and the parent's overflow:hidden + // crops the bleed. + // + // YouTube's newer player paints a big center play/prev/next overlay + // we can't crop around anyway, so there's no point oversizing + // further at the cost of seeing less of the actual trailer. 30% + // bleed catches the edge chrome and lets the trailer composition + // stay mostly intact. + // + // height = 130% of card height (15% bleed above + below) + // width = height × 16/9 × (card_height / card_width) + // poster (h/w 1.5): width ≈ 346.67%, height 130% + // square (h/w 1): width ≈ 231.11%, height 130% + // landscape (h/w 0.5625): width = 130%, height 130% + const trailerCoverStyle: React.CSSProperties = + aspect === 'poster' + ? { width: '346.67%', height: '130%' } + : aspect === 'square' + ? { width: '231.11%', height: '130%' } + : { width: '130%', height: '130%' } + + return ( + { + // Ctrl/Cmd-click is a selection toggle, not a navigation. The + // parent decides whether to honor it via onToggleSelect. + if (onToggleSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) { + e.preventDefault() + e.stopPropagation() + onToggleSelect() + return + } + // When something is already selected, plain clicks toggle as well + // so the user can fluidly grow / shrink the selection. + if (onToggleSelect && selected) { + e.preventDefault() + e.stopPropagation() + onToggleSelect() + return + } + onClick?.() + }} + onMouseMove={handleMove} + onMouseEnter={() => setHovered(true)} + onMouseLeave={handleLeave} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onTouchMove={handleTouchEnd} + style={{ rotateX, rotateY, transformStyle: 'preserve-3d', transformPerspective: 1000 }} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }} + className={`group text-left w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-void ${compact ? 'rounded-[3px]' : 'rounded-lg'}`} + > +
+ {/* Skeleton */} + {!imgLoaded && !imgError &&
} + + {/* Image */} + {imageUrl && !imgError && ( + setImgLoaded(true)} + onError={() => setImgError(true)} + initial={{ opacity: 0, scale: 1.02 }} + animate={{ + opacity: imgLoaded ? 1 : 0, + scale: hovered ? 1.08 : 1, + }} + transition={{ + opacity: { duration: 0.45, ease: [0.16, 1, 0.3, 1] }, + scale: { duration: 0.7, ease: [0.16, 1, 0.3, 1] }, + }} + className="absolute inset-0 w-full h-full object-cover" + /> + )} + + {/* Image fallback (no image) */} + {(!imageUrl || imgError) && ( +
+ + {title[0]?.toUpperCase()} + +
+ )} + + {/* Hover trailer - YouTube embed with autoplay + mute. Sized to + COVER the card (not contain): the iframe is wider than the + parent for poster/square aspects so the 16:9 video fully + fills the visible frame and the sides crop. All YouTube + chrome is masked via player params + opaque overlays so the + user sees motion art, not a player. */} + {trailerStarted && trailer.videoKey && ( +
+