import { FocusContext, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { BatteryCharging, BatteryFull, BatteryLow, BatteryMedium, BatteryWarning, Bell, Bluetooth, CircleFadingArrowUp, Clock, Settings, Wifi, WifiHigh, WifiLow, WifiZero, } from "lucide-react"; import { RoundButton } from "./RoundButton"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { JSX, RefObject, useContext, useEffect, useRef, useState } from "react"; import { useStickyDataAttr } from "../scripts/utils"; import { twMerge } from "tailwind-merge"; import { TwitchIcon } from "../scripts/brandIcons"; import { rommLoggedInQuery } from "../scripts/queries/romm"; import { twitchLoginVerificationQuery } from "../scripts/queries/settings"; import { AppContext, SystemInfoContext } from "../scripts/contexts"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { oneShot } from "../scripts/audio/audio"; import { hasUpdateQuery } from "../scripts/queries/system"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; function HeaderAvatar (data: { id: string; preview?: string | JSX.Element; className?: string; active?: boolean; locked?: boolean; onSelect?: () => void; }) { return (
{typeof data.preview === 'string' ? (
) : data.preview}
); } export interface HeaderButton { id: string; icon: JSX.Element; external?: boolean; action?: () => void; className?: string; shortcutLabel?: string; } export interface HeaderAccount { id: string; preview?: string | JSX.Element; className?: string; type?: "base" | "primary" | "secondary" | "accent"; locked?: boolean; action?: () => void; } function UpdateStatus () { const handleSelect = () => { navigate({ to: '/settings/update' }); }; const hasUnread = false; const navigate = useNavigate(); const { ref } = useFocusable({ focusKey: 'update-bt', onEnterPress: handleSelect }); return
; } function NotificationStatus () { const hasUnread = false; return
; } function ClockStatus () { const navigate = useNavigate(); const app = useContext(AppContext); const refClock = useRef(null); const activeTaskProgress = app.activeTaskProgress; const handleTaskClick = () => { navigate({ to: '/settings/tasks' }); }; const { ref, focusKey } = useFocusable({ focusKey: 'tasks-indicator', focusable: !!activeTaskProgress, onEnterPress: handleTaskClick }); useEffect(() => { function update () { if (refClock.current) { refClock.current.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } } // Update immediately update(); // Wait until next minute boundary const now = new Date(); const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds(); const timeout = setTimeout(() => { update(); // Then update every minute const interval = setInterval(update, 60_000); return () => clearInterval(interval); }, msUntilNextMinute); return () => clearTimeout(timeout); }, []); useShortcuts(focusKey, () => [{ label: "Downloads", button: GamePadButtonCode.A, action (e) { handleTaskClick(); }, }]); return
{activeTaskProgress ?
: }
; } function BluetoothStatus () { const systemContext = useContext(SystemInfoContext); return systemContext?.bluetoothDevices.find(b => b.connected) &&
; } function WiFiStatus () { const systemContext = useContext(SystemInfoContext); return systemContext && systemContext.wifiConnections.length > 0 ?
{systemContext.wifiConnections.map(w => { const className = "w-10 h-10"; let icon = ; if (w.signalLevel >= -60) icon = ; else if (w.signalLevel >= -70) icon = ; else if (w.signalLevel >= -80) icon = ; else if (w.signalLevel >= -90) icon = ; return
{icon}
; })}
: undefined; } function BatteryStatus () { const systemContext = useContext(SystemInfoContext); const batteryClassName = "md:size-10 sm:size-6"; let batteryIcon = ; if (systemContext) { if (systemContext.battery.isCharging || systemContext.battery.acConnected) { batteryIcon = ; } else if (systemContext.battery.percent) { if (systemContext.battery.percent < 5) { batteryIcon = ; } else if (systemContext.battery.percent < 15) { batteryIcon = ; } else if (systemContext.battery.percent < 50) { batteryIcon = ; } } } return !!systemContext?.battery.hasBattery &&
{batteryIcon} {systemContext.battery?.percent} %
; } export function HeaderAccounts (data: { accounts?: HeaderAccount[]; }) { const rommUser = useQuery({ ...rommLoggedInQuery, placeholderData: keepPreviousData }); const twitchStatus = useQuery({ ...twitchLoginVerificationQuery, refetchOnWindowFocus: false, retry: 1, placeholderData: keepPreviousData }); const handleSelect = () => { router.navigate({ to: '/settings/accounts' }); oneShot('click'); }; const accounts: HeaderAccount[] = []; if (data.accounts) accounts.push(...data.accounts); if (rommUser.data?.hasLogin || rommUser.isError) { accounts.push({ id: 'romm', preview: `https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg`, className: rommUser.data?.hasLogin && !rommUser.isError ? undefined : "border-error", type: 'secondary' }); } if (twitchStatus.data) { accounts.push({ id: 'twitch', preview: TwitchIcon, type: 'secondary' }); } const hasAccounts = accounts.length > 0; const router = useRouter(); const { ref } = useFocusable({ focusKey: 'accounts', onEnterPress: handleSelect, focusable: hasAccounts }); return
{accounts?.map(a => )}
; } export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; }) { const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' }); const { data: update } = useQuery(hasUpdateQuery); return
{!!update && update.hasUpdate >= 1 && }
{!!data.buttons &&
}
{data.buttonElements} {data.buttons?.map(b => {b.icon})}
; } interface HeaderUIParams { buttons?: HeaderButton[]; accounts?: HeaderAccount[]; buttonElements?: JSX.Element[] | JSX.Element; title?: JSX.Element; preferredChildFocusKey?: string; focusable?: boolean; } export function HeaderUI (data: HeaderUIParams) { const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey }); const router = useRouter(); const goToSettings = () => { router.navigate({ to: '/settings/accounts' }); }; return (
{data.title} , id: "header-settings-btn", action: goToSettings, external: true, shortcutLabel: "Settings" } ]} />
); } export function StickyHeaderUI (data: { ref: RefObject; className?: string; children?: any; } & HeaderUIParams) { const [isStuck, setIsStuck] = useState(false); const headerRef = useRef(null); const sentinelRef = useRef(null); useStickyDataAttr(headerRef, sentinelRef, data.ref, setIsStuck); return <>
{data.children}
; }