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 (
)
}
/**
* 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. */}
}>
}
/>
)
}