472 lines
19 KiB
TypeScript
472 lines
19 KiB
TypeScript
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<any[]>([])
|
|
const [lastOpenedAt, setLastOpenedAt] = useState<string | null>(() => {
|
|
try { return localStorage.getItem('jf_bell_opened') } catch { return null }
|
|
})
|
|
const notifsRef = useRef<HTMLDivElement>(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<string>()
|
|
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 (
|
|
<header
|
|
data-tauri-drag-region
|
|
className={`app-header relative shrink-0 h-11 flex items-stretch z-sticky border-b transition-colors duration-300 ease-out ${
|
|
scrolled
|
|
? 'bg-glass-strong backdrop-blur-xl border-border'
|
|
: 'bg-void/95 border-border/60'
|
|
}`}
|
|
>
|
|
{/* ── Left: brand + pin ──────────────────────────────────── */}
|
|
<div data-tauri-drag-region className="flex items-center gap-2 pl-3 pr-2">
|
|
<NavLink
|
|
to="/"
|
|
aria-label="Home"
|
|
className="group flex items-center gap-2.5 focus-ring rounded-md px-1 py-1 -mx-1 hover:bg-glass-light transition-colors"
|
|
>
|
|
<div className="relative w-6 h-6 shrink-0">
|
|
<div className="absolute inset-0 rounded-md bg-gradient-to-br from-accent to-accent-press" />
|
|
<div className="absolute inset-0 rounded-md bg-accent-glow blur-[6px] opacity-50" />
|
|
<div className="relative w-full h-full rounded-md grid place-items-center overflow-hidden">
|
|
<span
|
|
aria-hidden
|
|
className="block w-[64%] h-[64%] bg-void"
|
|
style={{
|
|
WebkitMaskImage: 'url(/icon.svg)',
|
|
maskImage: 'url(/icon.svg)',
|
|
WebkitMaskRepeat: 'no-repeat',
|
|
maskRepeat: 'no-repeat',
|
|
WebkitMaskPosition: 'center',
|
|
maskPosition: 'center',
|
|
WebkitMaskSize: 'contain',
|
|
maskSize: 'contain',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="hidden sm:flex flex-col leading-none">
|
|
<span className="text-[11.5px] font-bold tracking-[0.18em] text-text-1 font-display">
|
|
JELLYBLOOM
|
|
</span>
|
|
<span className="text-[8.5px] uppercase tracking-[0.2em] font-semibold text-text-4 mt-0.5">
|
|
Media client
|
|
</span>
|
|
</span>
|
|
</NavLink>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onTogglePin}
|
|
aria-label={pinned ? 'Unpin sidebar' : 'Pin sidebar open'}
|
|
aria-pressed={pinned}
|
|
title={pinned ? 'Unpin sidebar' : 'Pin sidebar open'}
|
|
className={`w-7 h-7 grid place-items-center rounded-md transition-colors duration-150 focus-ring ${
|
|
pinned
|
|
? 'text-accent bg-accent-dim hover:bg-accent/20'
|
|
: 'text-text-3 hover:text-text-1 hover:bg-glass-light'
|
|
}`}
|
|
>
|
|
{pinned ? <PinFilled size={13} /> : <Pin size={13} stroke={2} />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Middle: back button + page title (drag region) ─────── */}
|
|
<div data-tauri-drag-region className="flex-1 min-w-0 flex items-center gap-2 px-3">
|
|
{showBack && (
|
|
<button
|
|
onClick={() => navigate(-1)}
|
|
className="w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-glass-light transition-colors duration-150 focus-ring shrink-0"
|
|
aria-label="Back"
|
|
>
|
|
<ArrowLeft size={15} stroke={2} />
|
|
</button>
|
|
)}
|
|
<AnimatePresence mode="wait">
|
|
{title && (
|
|
<motion.h1
|
|
data-tauri-drag-region
|
|
key={title}
|
|
initial={{ opacity: 0, y: 4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
|
className="text-[13px] font-semibold text-text-1 tracking-tight truncate"
|
|
>
|
|
{title}
|
|
</motion.h1>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* ── Right: search + window controls ─────────────────────── */}
|
|
<div className="flex items-stretch">
|
|
<button
|
|
onClick={() => navigate('/search')}
|
|
className="group self-center mr-2 flex items-center gap-2 h-7 pl-2 pr-1.5 rounded-md bg-elevated/50 hover:bg-elevated border border-border hover:border-border-hover transition-all duration-200 ease-out text-text-3 hover:text-text-2 focus-ring"
|
|
aria-label="Search"
|
|
>
|
|
<Search size={12} strokeWidth={2} className="text-text-3 group-hover:text-accent transition-colors duration-200" />
|
|
<span className="text-[11px] hidden md:inline">Search anything</span>
|
|
<kbd className="hidden md:inline-flex items-center gap-0.5 ml-1 px-1.5 h-4 rounded text-[9.5px] font-mono text-text-4 bg-void/60 border border-border">
|
|
<span className="text-[8px]">^</span>K
|
|
</kbd>
|
|
</button>
|
|
|
|
{/* Notification bell — new releases only */}
|
|
<div ref={notifsRef} className="relative self-center mr-2">
|
|
<button
|
|
onClick={() => {
|
|
setNotifsOpen(o => {
|
|
const next = !o
|
|
if (next) {
|
|
const now = new Date().toISOString()
|
|
setLastOpenedAt(now)
|
|
try { localStorage.setItem('jf_bell_opened', now) } catch { /* noop */ }
|
|
}
|
|
return next
|
|
})
|
|
}}
|
|
className="relative w-7 h-7 grid place-items-center rounded-md text-text-3 hover:text-text-1 hover:bg-elevated transition-colors focus-ring"
|
|
aria-label="New releases"
|
|
>
|
|
<Bell size={13} stroke={2} />
|
|
{hasUnread && (
|
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
|
|
)}
|
|
</button>
|
|
<AnimatePresence>
|
|
{notifsOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 6, scale: 0.98 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 6, scale: 0.98 }}
|
|
transition={{ duration: 0.18 }}
|
|
className="absolute right-0 top-9 w-80 rounded-xl bg-glass-strong backdrop-blur-2xl border border-white/10 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.85)] z-[9999] overflow-hidden"
|
|
>
|
|
<div className="px-3 py-2.5 border-b border-white/8 flex items-center justify-between">
|
|
<p className="text-[11px] font-semibold text-text-1 tracking-tight">New releases</p>
|
|
{notifs.length > 0 && (
|
|
<span className="text-[10px] text-text-4 tabular-nums">{notifs.length} new</span>
|
|
)}
|
|
</div>
|
|
<div className="max-h-72 overflow-y-auto content-scroll">
|
|
{notifs.length === 0 ? (
|
|
<p className="px-3 py-4 text-[11.5px] text-text-3 text-center">Nothing new yet</p>
|
|
) : (
|
|
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 (
|
|
<button
|
|
key={entry.Id}
|
|
onClick={() => {
|
|
if (entry.Id) {
|
|
navigate(`/item/${entry.Id}`)
|
|
setNotifsOpen(false)
|
|
}
|
|
}}
|
|
className={`w-full text-left px-3 py-2.5 text-[11.5px] border-b border-white/5 last:border-0 hover:bg-white/4 transition-colors flex items-start gap-3 ${isUnread ? 'bg-white/[0.03]' : ''}`}
|
|
>
|
|
{posterUrl ? (
|
|
<img
|
|
src={posterUrl}
|
|
alt=""
|
|
className="w-8 aspect-[2/3] rounded object-cover shrink-0 bg-void"
|
|
loading="lazy"
|
|
/>
|
|
) : isEpisode ? (
|
|
<Tv size={13} className="text-accent shrink-0 mt-0.5" />
|
|
) : (
|
|
<Film size={13} className="text-accent shrink-0 mt-0.5" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
{isEpisode && seriesName ? (
|
|
<>
|
|
<p className="text-text-2 truncate font-medium">{seriesName}</p>
|
|
{epLabel && (
|
|
<p className="text-text-3 truncate">{epLabel}</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-text-2 truncate font-medium">{entry.Name}</p>
|
|
)}
|
|
<p className="text-text-4 tabular-nums mt-0.5">
|
|
{entry.DateCreated ? new Date(entry.DateCreated).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}
|
|
{entry.ProductionYear && !isEpisode ? ` · ${entry.ProductionYear}` : ''}
|
|
</p>
|
|
</div>
|
|
{isUnread && (
|
|
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0 mt-1.5" />
|
|
)}
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{isTauri && (
|
|
<>
|
|
<WinButton onClick={() => callWin('minimize')} aria-label="Minimize">
|
|
<Minus size={14} stroke={2} />
|
|
</WinButton>
|
|
<WinButton
|
|
onClick={() => callWin('toggleMaximize')}
|
|
aria-label={maximized ? 'Restore' : 'Maximize'}
|
|
>
|
|
{maximized ? <RestoreDown size={12} stroke={2} /> : <Square size={12} stroke={2} />}
|
|
</WinButton>
|
|
<WinButton onClick={() => callWin('close')} aria-label="Close" variant="close">
|
|
<X size={14} stroke={2.25} />
|
|
</WinButton>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
function WinButton({
|
|
children,
|
|
variant,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'close' }) {
|
|
return (
|
|
<button
|
|
{...props}
|
|
className={`w-11 h-full grid place-items-center text-text-3 transition-colors duration-100 focus-ring ${
|
|
variant === 'close'
|
|
? 'hover:bg-red-500 hover:text-white'
|
|
: 'hover:bg-elevated hover:text-text-1'
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|