diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index f90339d..0000000
--- a/src/App.css
+++ /dev/null
@@ -1,184 +0,0 @@
-.counter {
- font-size: 16px;
- padding: 5px 10px;
- border-radius: 5px;
- color: var(--accent);
- background: var(--accent-bg);
- border: 2px solid transparent;
- transition: border-color 0.3s;
- margin-bottom: 24px;
-
- &:hover {
- border-color: var(--accent-border);
- }
- &:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-}
-
-.hero {
- position: relative;
-
- .base,
- .framework,
- .vite {
- inset-inline: 0;
- margin: 0 auto;
- }
-
- .base {
- width: 170px;
- position: relative;
- z-index: 0;
- }
-
- .framework,
- .vite {
- position: absolute;
- }
-
- .framework {
- z-index: 1;
- top: 34px;
- height: 28px;
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
- scale(1.4);
- }
-
- .vite {
- z-index: 0;
- top: 107px;
- height: 26px;
- width: auto;
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
- scale(0.8);
- }
-}
-
-#center {
- display: flex;
- flex-direction: column;
- gap: 25px;
- place-content: center;
- place-items: center;
- flex-grow: 1;
-
- @media (max-width: 1024px) {
- padding: 32px 20px 24px;
- gap: 18px;
- }
-}
-
-#next-steps {
- display: flex;
- border-top: 1px solid var(--border);
- text-align: left;
-
- & > div {
- flex: 1 1 0;
- padding: 32px;
- @media (max-width: 1024px) {
- padding: 24px 20px;
- }
- }
-
- .icon {
- margin-bottom: 16px;
- width: 22px;
- height: 22px;
- }
-
- @media (max-width: 1024px) {
- flex-direction: column;
- text-align: center;
- }
-}
-
-#docs {
- border-right: 1px solid var(--border);
-
- @media (max-width: 1024px) {
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-}
-
-#next-steps ul {
- list-style: none;
- padding: 0;
- display: flex;
- gap: 8px;
- margin: 32px 0 0;
-
- .logo {
- height: 18px;
- }
-
- a {
- color: var(--text-h);
- font-size: 16px;
- border-radius: 6px;
- background: var(--social-bg);
- display: flex;
- padding: 6px 12px;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- transition: box-shadow 0.3s;
-
- &:hover {
- box-shadow: var(--shadow);
- }
- .button-icon {
- height: 18px;
- width: 18px;
- }
- }
-
- @media (max-width: 1024px) {
- margin-top: 20px;
- flex-wrap: wrap;
- justify-content: center;
-
- li {
- flex: 1 1 calc(50% - 8px);
- }
-
- a {
- width: 100%;
- justify-content: center;
- box-sizing: border-box;
- }
- }
-}
-
-#spacer {
- height: 88px;
- border-top: 1px solid var(--border);
- @media (max-width: 1024px) {
- height: 48px;
- }
-}
-
-.ticks {
- position: relative;
- width: 100%;
-
- &::before,
- &::after {
- content: '';
- position: absolute;
- top: -4.5px;
- border: 5px solid transparent;
- }
-
- &::before {
- left: 0;
- border-left-color: var(--border);
- }
- &::after {
- right: 0;
- border-right-color: var(--border);
- }
-}
diff --git a/src/App.tsx b/src/App.tsx
index a66b5ef..b16da19 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,122 +1,268 @@
-import { useState } from 'react'
-import reactLogo from './assets/react.svg'
-import viteLogo from './assets/vite.svg'
-import heroImg from './assets/hero.png'
-import './App.css'
+import { useState, useEffect, lazy, Suspense } from 'react'
+import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
+import { AnimatePresence, motion } from 'framer-motion'
+import { jellyfinClient } from './api/jellyfin'
+import type { AuthState } from './api/types'
+import { usePreferencesStore } from './stores/preferences-store'
+import { isTauri } from './lib/tauri'
+import ErrorBoundary from './components/ui/ErrorBoundary'
+import AppShell from './components/layout/AppShell'
+import Titlebar from './components/layout/Titlebar'
+import LoginPage from './pages/LoginPage'
+import HomePage from './pages/HomePage'
+import { useNewReleaseNotifications } from './hooks/use-new-releases'
-function App() {
- const [count, setCount] = useState(0)
+const LibraryPage = lazy(() => import('./pages/LibraryPage'))
+const DetailPage = lazy(() => import('./pages/DetailPage'))
+const MusicPage = lazy(() => import('./pages/MusicPage'))
+const SearchPage = lazy(() => import('./pages/SearchPage'))
+const DiscoverPage = lazy(() => import('./pages/DiscoverPage'))
+const SettingsPage = lazy(() => import('./pages/SettingsPage'))
+const PlayerPage = lazy(() => import('./pages/PlayerPage'))
+const PersonPage = lazy(() => import('./pages/PersonPage'))
+const CollectionPage = lazy(() => import('./pages/CollectionPage'))
+const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage'))
+const ProfilePage = lazy(() => import('./pages/ProfilePage'))
+const StatsPage = lazy(() => import('./pages/StatsPage'))
+const DuplicatesPage = lazy(() => import('./pages/DuplicatesPage'))
+const DownloadsPage = lazy(() => import('./pages/DownloadsPage'))
+const LiveTvPage = lazy(() => import('./pages/LiveTvPage'))
+const RequestsPage = lazy(() => import('./pages/RequestsPage'))
+
+const pageVariants = {
+ initial: { opacity: 0, y: 8 },
+ animate: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: -4 },
+}
+
+const pageTransition = {
+ duration: 0.2,
+ ease: [0, 0, 0.2, 1] as [number, number, number, number],
+}
+
+/**
+ * Wraps the entire window so every state of the app (loading, login, main
+ * shell) shares the same titlebar + content layout. The titlebar is only
+ * mounted when running inside Tauri - in a browser, the wrapper just shrinks
+ * to nothing and the content fills the viewport like before.
+ */
+function WindowFrame({ children }: { children: React.ReactNode }) {
+ const [fullscreen, setFullscreen] = useState(false)
+
+ useEffect(() => {
+ const sync = () => setFullscreen(Boolean(document.fullscreenElement))
+ sync()
+ document.addEventListener('fullscreenchange', sync)
+ return () => document.removeEventListener('fullscreenchange', sync)
+ }, [])
return (
- <>
-
-
-
-
Get started
-
- Edit src/App.tsx and save to test HMR
-
-
-
-
-
-
-
-
-
-
-
Documentation
-
Your questions, answered
-
-
-
-
-
Connect with us
-
Join the Vite community
-
-
-
-
-
-
- >
+
+ {isTauri && !fullscreen &&
}
+
{children}
+
)
}
-export default App
+function PageMotion({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function PageFallback() {
+ return (
+
+ )
+}
+
+/**
+ * Renders the landing page chosen in Settings ("home" / "movies" / "shows").
+ * Reads the pref synchronously so initial mount doesn't flicker through Home.
+ */
+function LandingRedirect() {
+ const landing = usePreferencesStore(s => s.defaultLanding)
+ if (landing === 'movies') return
+ if (landing === 'shows') return
+ return
+}
+
+export default function App() {
+ const [auth, setAuth] = useState(undefined)
+ const [loading, setLoading] = useState(true)
+ const location = useLocation()
+ const uiZoom = usePreferencesStore(s => s.uiZoom)
+ const pushNotifications = usePreferencesStore(s => s.pushNotifications)
+ useNewReleaseNotifications(pushNotifications)
+
+ useEffect(() => {
+ jellyfinClient.tryAutoLogin().then(result => {
+ setAuth(result)
+ setLoading(false)
+ })
+ }, [])
+
+ // Apply the user's interface-zoom preference. In Tauri we use the native
+ // webview zoom API (Chromium's setZoom), which is the same path Ctrl+/Ctrl-
+ // takes - it reflows layout and re-evaluates media queries, so a 1.5x
+ // zoom collapses multi-column grids exactly the way a smaller window would.
+ // CSS `zoom` only scales rendered pixels (no reflow) so it's only used as
+ // the browser fallback where the OS-level webview API isn't available.
+ useEffect(() => {
+ const z = Number.isFinite(uiZoom) && uiZoom > 0 ? uiZoom : 1
+ if (isTauri) {
+ // Clear any leftover CSS zoom from a previous session
+ document.documentElement.style.zoom = ''
+ // Lazy-import so the @tauri-apps/api dep doesn't get pulled into a
+ // pure-browser build that doesn't need it.
+ import('@tauri-apps/api/webview').then(({ getCurrentWebview }) => {
+ getCurrentWebview().setZoom(z).catch(() => {})
+ }).catch(() => {})
+ } else {
+ document.documentElement.style.zoom = String(z)
+ }
+ }, [uiZoom])
+
+ // Apply custom accent color to CSS variables so the entire UI picks it up.
+ const accentColor = usePreferencesStore(s => s.accentColor)
+ useEffect(() => {
+ const root = document.documentElement
+ if (!accentColor || accentColor === '#F5B642') {
+ root.style.removeProperty('--color-accent')
+ root.style.removeProperty('--color-accent-hover')
+ root.style.removeProperty('--color-accent-press')
+ root.style.removeProperty('--color-accent-dim')
+ root.style.removeProperty('--color-accent-glow')
+ root.style.removeProperty('--color-accent-text')
+ root.style.removeProperty('--color-accent-deep')
+ } else {
+ root.style.setProperty('--color-accent', accentColor)
+ // Derive related shades from the base color. We use simple HSL
+ // shifts so the palette stays coherent without pulling in a color
+ // math library.
+ try {
+ const hex = accentColor.replace('#', '')
+ const r = parseInt(hex.slice(0, 2), 16) / 255
+ const g = parseInt(hex.slice(2, 4), 16) / 255
+ const b = parseInt(hex.slice(4, 6), 16) / 255
+ const max = Math.max(r, g, b)
+ const min = Math.min(r, g, b)
+ let h = 0
+ const l = (max + min) / 2
+ const s = max === min ? 0 : (max - min) / (1 - Math.abs(2 * l - 1))
+ if (max !== min) {
+ if (max === r) h = ((g - b) / (max - min) + (g < b ? 6 : 0)) * 60
+ else if (max === g) h = ((b - r) / (max - min) + 2) * 60
+ else h = ((r - g) / (max - min) + 4) * 60
+ }
+ const hover = `hsl(${h} ${Math.round(s * 100)}% ${Math.min(100, Math.round(l * 100) + 10)}%)`
+ const press = `hsl(${h} ${Math.round(s * 100)}% ${Math.max(0, Math.round(l * 100) - 8)}%)`
+ const dim = `hsla(${h} ${Math.round(s * 100)}% ${Math.round(l * 100)}% / 0.14)`
+ const glow = `hsla(${h} ${Math.round(s * 100)}% ${Math.round(l * 100)}% / 0.22)`
+ const text = `hsl(${h} ${Math.round(s * 100)}% ${Math.min(100, Math.round(l * 100) + 22)}%)`
+ const deep = `hsl(${h} ${Math.round(s * 100)}% ${Math.max(0, Math.round(l * 100) - 22)}%)`
+ root.style.setProperty('--color-accent-hover', hover)
+ root.style.setProperty('--color-accent-press', press)
+ root.style.setProperty('--color-accent-dim', dim)
+ root.style.setProperty('--color-accent-glow', glow)
+ root.style.setProperty('--color-accent-text', text)
+ root.style.setProperty('--color-accent-deep', deep)
+ } catch {
+ // If the color value is malformed, fall back to just the base.
+ }
+ }
+ }, [accentColor])
+
+ if (loading) {
+ return (
+
+
+
+
+
+
+ j
+
+
+
+
+ Connecting to your server
+
+
+
+ )
+ }
+
+ if (!auth) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* AppShell renders its own complete chrome (AppHeader includes
+ window controls + drag region), so it doesn't get wrapped in
+ WindowFrame - that would stack two titlebars. */}
+ { jellyfinClient.logout(); setAuth(null) }} />}>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* Player needs the simple Titlebar so the user can still close
+ the window during playback. */}
+
+ }>
+
+
+
+ }
+ />
+
+
+
+ )
+}
diff --git a/src/index.css b/src/index.css
index 5fb3313..f3e42de 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,111 +1,403 @@
-:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
+/* Self-hosted-style fonts via Bunny (privacy-respecting Google Fonts proxy) */
+@import url('https://fonts.bunny.net/css?family=geist:400,500,600,700|geist-mono:400,500,600|unbounded:400,500,600,700,800,900&display=swap');
+@import "tailwindcss";
+@import "leaflet/dist/leaflet.css";
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
+@theme {
+ /* ─── Surfaces (layered dark with warm undertone) ─────────────── */
+ --color-void: #07080A;
+ --color-surface: #0F1114;
+ --color-elevated: #15181D;
+ --color-higher: #1D2127;
+ --color-muted: #2A2F38;
+ --color-line: #3A3F4A;
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+ /* Glass surfaces (use with backdrop-blur) */
+ --color-glass: rgba(15, 17, 20, 0.72);
+ --color-glass-strong: rgba(15, 17, 20, 0.88);
+ --color-glass-light: rgba(255, 255, 255, 0.04);
- @media (max-width: 1024px) {
- font-size: 16px;
+ /* ─── Text (luminance-disciplined dark mode) ─────────────────── */
+ --color-text-1: rgba(255, 255, 255, 0.95);
+ --color-text-2: rgba(255, 255, 255, 0.68);
+ --color-text-3: rgba(255, 255, 255, 0.46);
+ --color-text-4: rgba(255, 255, 255, 0.28);
+ --color-text-5: rgba(255, 255, 255, 0.16);
+
+ /* ─── Accent (refined warm amber, the Jellyfin heritage) ─────── */
+ --color-accent: #F5B642;
+ --color-accent-hover: #FFC656;
+ --color-accent-press: #E5A82E;
+ --color-accent-dim: rgba(245, 182, 66, 0.14);
+ --color-accent-glow: rgba(245, 182, 66, 0.22);
+ --color-accent-text: #FFD17A;
+ --color-accent-deep: #B07A1C;
+
+ /* Secondary cool accent for contrast moments */
+ --color-cool: #6BA3FF;
+ --color-cool-dim: rgba(107, 163, 255, 0.12);
+
+ /* ─── Semantic ───────────────────────────────────────────────── */
+ --color-success: #4ADE80;
+ --color-warning: #FBBF24;
+ --color-error: #FB7185;
+ --color-info: #60A5FA;
+
+ /* ─── Borders (hairline progression) ─────────────────────────── */
+ --color-border: rgba(255, 255, 255, 0.07);
+ --color-border-hover: rgba(255, 255, 255, 0.12);
+ --color-border-strong: rgba(255, 255, 255, 0.18);
+
+ /* ─── Skeleton ───────────────────────────────────────────────── */
+ --color-skeleton-base: rgba(255, 255, 255, 0.035);
+ --color-skeleton-shine: rgba(255, 255, 255, 0.085);
+
+ /* ─── Typography ─────────────────────────────────────────────── */
+ --font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', sans-serif;
+ --font-mono: 'Geist Mono', 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
+ --font-display: 'Unbounded', 'Geist', system-ui, sans-serif;
+
+ /* ─── Radii (the rounded-but-intentional scale) ──────────────── */
+ --radius-xs: 4px;
+ --radius-sm: 8px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+ --radius-xl: 18px;
+ --radius-2xl: 24px;
+ --radius-pill: 999px;
+
+ /* ─── Shadow (cinematic layered depth) ───────────────────────── */
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25);
+ --shadow-sm: 0 2px 6px -1px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.4);
+ --shadow-md: 0 8px 20px -6px rgba(0, 0, 0, 0.5), 0 2px 6px -2px rgba(0, 0, 0, 0.45);
+ --shadow-lg: 0 24px 48px -16px rgba(0, 0, 0, 0.65), 0 6px 16px -8px rgba(0, 0, 0, 0.5);
+ --shadow-xl: 0 40px 80px -20px rgba(0, 0, 0, 0.75), 0 12px 24px -12px rgba(0, 0, 0, 0.6);
+ --shadow-glow: 0 0 0 1px var(--color-accent-glow), 0 0 32px -4px var(--color-accent-glow);
+ --shadow-inset-edge: inset 0 1px 0 rgba(255, 255, 255, 0.05);
+
+ /* ─── Motion (spring + snappy duo) ───────────────────────────── */
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-standard: cubic-bezier(0.2, 0, 0, 1);
+ --duration-instant: 100ms;
+ --duration-fast: 160ms;
+ --duration-base: 220ms;
+ --duration-slow: 320ms;
+ --duration-slower: 480ms;
+ --duration-cinema: 700ms;
+
+ /* ─── Z-index layering ───────────────────────────────────────── */
+ --z-base: 0;
+ --z-sticky: 25;
+ --z-sidebar: 10;
+ --z-mini-player: 20;
+ --z-dropdown: 30;
+ --z-modal-scrim: 40;
+ --z-modal: 50;
+ --z-toast: 60;
+ --z-video-overlay: 70;
+ --z-fullscreen: 80;
+
+ /* ─── Keyframes ──────────────────────────────────────────────── */
+ @keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+ }
+ @keyframes pulse-glow {
+ 0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
+ 50% { box-shadow: 0 0 0 6px transparent; }
+ }
+ @keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+ }
+ @keyframes rise-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes slow-zoom {
+ from { transform: scale(1); }
+ to { transform: scale(1.06); }
+ }
+ @keyframes spin-soft {
+ to { transform: rotate(360deg); }
+ }
+ @keyframes equalizer-bar {
+ 0%, 100% { transform: scaleY(0.35); }
+ 50% { transform: scaleY(1); }
}
}
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
+/* ──────────────────────────────────────────────────────────────── */
+/* Base layer */
+/* ──────────────────────────────────────────────────────────────── */
+@layer base {
+ *, *::before, *::after {
+ box-sizing: border-box;
}
- #social .button-icon {
- filter: invert(1) brightness(2);
+ html, body, #root {
+ height: 100%;
+ overflow: hidden;
+ }
+
+ html {
+ -webkit-text-size-adjust: 100%;
+ text-rendering: optimizeLegibility;
+ }
+
+ body {
+ font-family: var(--font-sans);
+ font-feature-settings: 'ss01', 'ss02', 'cv11', 'cv05';
+ font-optical-sizing: auto;
+ background:
+ radial-gradient(1200px 800px at 12% -8%, rgba(245, 182, 66, 0.04), transparent 60%),
+ radial-gradient(900px 600px at 92% 6%, rgba(107, 163, 255, 0.025), transparent 55%),
+ var(--color-void);
+ color: var(--color-text-1);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ letter-spacing: -0.005em;
+ }
+
+ ::selection {
+ background: var(--color-accent-glow);
+ color: var(--color-text-1);
+ }
+
+ /* Hide focus ring for mouse, keep for keyboard */
+ :focus { outline: none; }
+ :focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+ border-radius: var(--radius-sm);
+ }
+
+ /* ─── Scrollbars ─────────────────────────────────────────────── */
+ .content-scroll::-webkit-scrollbar { width: 10px; height: 10px; }
+ .content-scroll::-webkit-scrollbar-track { background: transparent; }
+ .content-scroll::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.06);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+ border-radius: 999px;
+ transition: background var(--duration-base);
+ }
+ .content-scroll:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); background-clip: padding-box; }
+ .content-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); background-clip: padding-box; }
+
+ .hide-scrollbar::-webkit-scrollbar { display: none; }
+ .hide-scrollbar { scrollbar-width: none; -ms-overflow-style: none; }
+
+ /* ─── Player video fill ────────────────────────────────────
+ * Override vidstack's default 16:9 aspect-ratio so the video
+ * element fills its container and adapts to the source's actual
+ * aspect ratio (4:3, 21:9, vertical, etc).
+ */
+ .player-fill {
+ aspect-ratio: unset !important;
+ }
+ .player-fill [data-media-provider] {
+ aspect-ratio: unset !important;
+ height: 100%;
+ }
+ .player-fill video {
+ width: 100% !important;
+ height: 100% !important;
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: unset !important;
+ object-fit: contain !important;
+ }
+
+ /* ─── Subtitle font size via CSS custom property ────────── */
+ [style*="--cue-font-size"] [data-cue] {
+ font-size: var(--cue-font-size);
+ }
+ @media (min-width: 768px) {
+ [style*="--cue-font-size-md"] [data-cue] {
+ font-size: var(--cue-font-size-md);
+ }
+ }
+
+ /* ─── Fullscreen: hide every chrome element that lives outside
+ * the fullscreen target. The video element is what goes fullscreen
+ * (via vidstack's request), so a plain `:fullscreen .app-titlebar`
+ * descendant selector misses - the titlebar is a SIBLING of the
+ * fullscreen target, not a descendant. `body:has(:fullscreen)`
+ * inverts the relationship so we can hide anywhere in the body
+ * while a fullscreen element exists.
+ */
+ body:has(:fullscreen) .app-header,
+ body:has(:fullscreen) .app-sidebar,
+ body:has(:fullscreen) .app-titlebar {
+ display: none !important;
+ }
+
+ /* ─── Theater / zen mode ─────────────────────────────────────
+ * When data-theater="true" on the player root, hide the bottom
+ * controls bar entirely and keep only a slim scrubber strip plus
+ * the click-capture surface. The top bar fades out via opacity so
+ * window controls are still reachable on a focus event.
+ */
+ [data-theater="true"] [data-player-top-bar] {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 200ms ease;
+ }
+ [data-theater="true"]:hover [data-player-top-bar] {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ [data-theater="true"] [data-player-bottom-bar] {
+ transform: translateY(calc(100% - 6px));
+ transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ [data-theater="true"] [data-player-bottom-bar]:hover {
+ transform: translateY(0);
+ }
+
+ /* ─── Subtitle size slider ───────────────────────────────── */
+ .subtitle-size-slider::-webkit-slider-track {
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(255,255,255,0.12);
+ }
+ .subtitle-size-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: white;
+ margin-top: -6px;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+ cursor: pointer;
+ }
+ .subtitle-size-slider::-moz-range-track {
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(255,255,255,0.12);
+ border: none;
+ }
+ .subtitle-size-slider::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: white;
+ box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+ cursor: pointer;
+ border: none;
+ }
+
+ /* ─── Utilities ──────────────────────────────────────────────── */
+ .skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--color-skeleton-base) 25%,
+ var(--color-skeleton-shine) 50%,
+ var(--color-skeleton-base) 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.6s infinite ease-in-out;
+ border-radius: var(--radius-md);
+ }
+
+ .glass {
+ background: var(--color-glass);
+ backdrop-filter: blur(24px) saturate(140%);
+ -webkit-backdrop-filter: blur(24px) saturate(140%);
+ border: 1px solid var(--color-border);
+ }
+
+ .glass-strong {
+ background: var(--color-glass-strong);
+ backdrop-filter: blur(32px) saturate(160%);
+ -webkit-backdrop-filter: blur(32px) saturate(160%);
+ }
+
+ .noise {
+ position: relative;
+ isolation: isolate;
+ }
+ .noise::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0.04;
+ mix-blend-mode: overlay;
+ background-image: url("data:image/svg+xml;utf8,");
+ }
+
+ .focus-ring {
+ transition: box-shadow var(--duration-fast) var(--ease-standard);
+ }
+ .focus-ring:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 2px var(--color-void), 0 0 0 4px var(--color-accent);
+ }
+
+ /* Animated gradient stroke for hero / featured chrome */
+ .accent-stroke {
+ background: linear-gradient(
+ 120deg,
+ transparent 30%,
+ var(--color-accent-glow) 50%,
+ transparent 70%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 4s infinite linear;
+ }
+
+ /* Text gradient for titles */
+ .text-luxe {
+ background: linear-gradient(180deg, rgba(255,255,255,1), rgba(255,255,255,0.78));
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: transparent;
+ }
+
+ /* Range input styling baseline (used by sliders) */
+ input[type="range"].slider {
+ -webkit-appearance: none;
+ appearance: none;
+ background: transparent;
+ cursor: pointer;
+ height: 16px;
+ }
+ input[type="range"].slider::-webkit-slider-runnable-track {
+ height: 4px;
+ background: rgba(255, 255, 255, 0.10);
+ border-radius: 999px;
+ }
+ input[type="range"].slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ background: var(--color-accent);
+ margin-top: -5px;
+ box-shadow: 0 0 0 4px transparent;
+ transition: box-shadow var(--duration-fast), transform var(--duration-fast);
+ }
+ input[type="range"].slider:hover::-webkit-slider-thumb {
+ box-shadow: 0 0 0 6px var(--color-accent-glow);
+ transform: scale(1.05);
+ }
+ input[type="range"].slider:active::-webkit-slider-thumb {
+ transform: scale(1.15);
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
}
}
-
-#root {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
-}
-
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
-}
-
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
-}
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..082ea97 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,17 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { QueryClientProvider } from '@tanstack/react-query'
+import { queryClient } from './lib/query-client'
+import App from './App'
import './index.css'
-import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
-
+
+
+
+
+
,
)
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..5cd211b
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_TMDB_API_KEY?: string
+ readonly VITE_FANART_API_KEY?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
Connect with us
-Join the Vite community
---
-
-
- GitHub
-
-
- -
-
-
- Discord
-
-
- -
-
-
- X.com
-
-
- -
-
-
- Bluesky
-
-
-
-