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' 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 (
{isTauri && !fullscreen && }
{children}
) } function PageMotion({ children }: { children: React.ReactNode }) { return ( {children} ) } function PageFallback() { return (
Loading
) } /** * 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. */} }> } /> ) }