import { useEffect, useRef, useState } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' import { motion, AnimatePresence } from 'framer-motion' import { Search, ArrowLeft, Pin, PinFilled, Minus, Square, RestoreDown, X, Bell, Film, Tv, } from '../../lib/icons' import { isTauri } from '../../lib/tauri' import { jellyfinClient, getItemsApi, getImageUrl, getStoredServerUrl } 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([]) const [lastOpenedAt, setLastOpenedAt] = useState(() => { try { return localStorage.getItem('jf_bell_opened') } catch { return null } }) const notifsRef = useRef(null) // Poll recently-added episodes + movies for the notification bell. // Episodes are deduplicated by series so a full-season drop doesn't // flood the list -- only the newest episode per series is shown. useEffect(() => { const api = jellyfinClient.getApi() if (!api) return async function poll() { try { const auth = jellyfinClient.getAuthState() if (!auth?.userId) return const [movieRes, episodeRes] = await Promise.all([ getItemsApi(api!).getItems({ userId: auth.userId, sortBy: ['DateCreated'], sortOrder: ['Descending'], limit: 10, recursive: true, includeItemTypes: ['Movie'], fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags'] as any[], }), getItemsApi(api!).getItems({ userId: auth.userId, sortBy: ['DateCreated'], sortOrder: ['Descending'], limit: 50, recursive: true, includeItemTypes: ['Episode'], fields: ['DateCreated', 'PrimaryImageAspectRatio', 'ImageTags', 'SeriesName', 'SeriesId', 'SeriesPrimaryImageTag', 'ParentIndexNumber', 'IndexNumber'] as any[], }), ]) const movies = movieRes.data.Items || [] const episodes = episodeRes.data.Items || [] // Deduplicate episodes by series -- keep only the newest per show const seenSeries = new Set() const dedupedEpisodes: any[] = [] for (const ep of episodes) { const sid = ep.SeriesId || ep.SeriesName if (!sid || seenSeries.has(sid)) continue seenSeries.add(sid) dedupedEpisodes.push(ep) } // Interleave: one movie, one episode, one movie... capped at 15 const interleaved: any[] = [] let m = 0, e = 0 while (interleaved.length < 15 && (m < movies.length || e < dedupedEpisodes.length)) { if (m < movies.length) interleaved.push(movies[m++]) if (interleaved.length >= 15) break if (e < dedupedEpisodes.length) interleaved.push(dedupedEpisodes[e++]) } setNotifs(interleaved) } catch { /* ignore */ } } poll() const id = setInterval(poll, 60_000) return () => clearInterval(id) }, []) // Click outside to close useEffect(() => { if (!notifsOpen) return function onDocClick(e: MouseEvent) { if (!notifsRef.current?.contains(e.target as Node)) { setNotifsOpen(false) } } document.addEventListener('mousedown', onDocClick) return () => document.removeEventListener('mousedown', onDocClick) }, [notifsOpen]) const hasUnread = notifs.some(n => { const created = n.DateCreated if (!created) return false if (!lastOpenedAt) return true return created > lastOpenedAt }) // 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 import('@tauri-apps/api/window').then(({ getCurrentWindow }) => { if (cancelled) return 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 import('@tauri-apps/api/window').then(({ getCurrentWindow }) => { 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 ──────────────────────────────────── */}
JELLYBLOOM Media client
{/* ── Middle: back button + page title (drag region) ─────── */}
{showBack && ( )} {title && ( {title} )}
{/* ── Right: search + window controls ─────────────────────── */}
{/* Notification bell — new releases only */}
{notifsOpen && (

New releases

{notifs.length > 0 && ( {notifs.length} new )}
{notifs.length === 0 ? (

Nothing new yet

) : ( notifs.map((entry: any) => { const isUnread = !lastOpenedAt || (entry.DateCreated && entry.DateCreated > lastOpenedAt) const isEpisode = entry.Type === 'Episode' const seriesName = entry.SeriesName const season = entry.ParentIndexNumber const epNum = entry.IndexNumber const epLabel = isEpisode && (season != null || epNum != null) ? `S${season ?? '?'}E${epNum ?? '?'}${entry.Name ? ` · ${entry.Name}` : ''}` : null // Poster thumbnail: series poster for episodes, movie poster for movies const serverUrl = getStoredServerUrl() const posterUrl = (() => { if (!serverUrl) return null if (isEpisode && entry.SeriesId && entry.SeriesPrimaryImageTag) { return getImageUrl(serverUrl, entry.SeriesId, 'Primary', 160, entry.SeriesPrimaryImageTag) } if (entry.ImageTags?.Primary) { return getImageUrl(serverUrl, entry.Id, 'Primary', 160, entry.ImageTags.Primary) } return null })() return ( ) }) )}
)}
{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 ( ) }