gameflow-deck/src/mainview/components/Header.tsx
Simeon Radivoev 9141fb35d4
feat: Implemented link game importing
feat: Implemented download page for downloading roms from various sources using plugins. Added support for internet archive external plugin.
feat: Added tasks page to track running tasks/downloads
feat: Added tanstack caching
feat: Added quick play action Fixes #6
feat: Added quick emulator launch action
fix: Made task queue only support 1 task per group and task ID should now be unique
2026-05-15 13:50:55 +03:00

383 lines
No EOL
12 KiB
TypeScript

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 (
<div
id={data.id}
onClick={data.onSelect}
style={{ viewTransitionName: `header-account-${data.id}` }}
className={twMerge(
`avatar overflow-visible bg-base-100 indicator border-7 sm:size-8 md:size-14 rounded-full flex items-center justify-center drop-shadow-md`,
data.className,
)}
>
{typeof data.preview === 'string' ? (
<div className="overflow rounded-full w-full h-full">
<picture>
<img key={"og-image"} src={data.preview}></img>
</picture>
</div>
) : data.preview}
</div>
);
}
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 <div onClick={handleSelect} ref={ref} className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full focusable focusable-primary focusable-hover focused:bg-warning cursor-pointer", { "bg-warning text-warning-content ": hasUnread })} data-tip="Update Available">
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning in-focused:text-warning-content" />
</div>;
}
function NotificationStatus ()
{
const hasUnread = false;
return <div className={classNames("p-2 rounded-full focused:bg-base-300", { "bg-warning text-warning-content": hasUnread })}>
<Bell className="sm:size-4 md:size-8" />
</div>;
}
function ClockStatus ()
{
const navigate = useNavigate();
const app = useContext(AppContext);
const refClock = useRef<HTMLSpanElement>(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 <div ref={ref} className="flex gap-3 sm:text-xs md:text-2xl items-center">
<span ref={refClock}></span>
{activeTaskProgress ? <div onClick={handleTaskClick} className={twMerge("radial-progress bg-primary text-primary-content border-primary border-4 in-focused:ring-7 in-focused:ring-primary in-focused:bg-base-content in-focused:text-base-200 in-focused:border-base-content", activeTaskProgress ? "cursor-pointer" : "")} style={{ "--value": activeTaskProgress, "--size": "2rem", "--thickness": "0.3rem" }} role="progressbar"></div> : <Clock className="sm:size-4 md:size-8" />}</div>;
}
function BluetoothStatus ()
{
const systemContext = useContext(SystemInfoContext);
return systemContext?.bluetoothDevices.find(b => b.connected) && <div>
<Bluetooth className="w-6 h-6" />
</div>;
}
function WiFiStatus ()
{
const systemContext = useContext(SystemInfoContext);
return systemContext && systemContext.wifiConnections.length > 0 ? <div>
{systemContext.wifiConnections.map(w =>
{
const className = "w-10 h-10";
let icon = <Wifi className={className} />;
if (w.signalLevel >= -60)
icon = <Wifi className={className} />;
else if (w.signalLevel >= -70)
icon = <WifiHigh className={className} />;
else if (w.signalLevel >= -80)
icon = <WifiLow className={className} />;
else if (w.signalLevel >= -90)
icon = <WifiZero className={className} />;
return <div className="tooltip tooltip-bottom" data-tip={w.signalLevel}>
{icon}
</div>;
})}
</div> : undefined;
}
function BatteryStatus ()
{
const systemContext = useContext(SystemInfoContext);
const batteryClassName = "md:size-10 sm:size-6";
let batteryIcon = <BatteryFull className={batteryClassName} />;
if (systemContext)
{
if (systemContext.battery.isCharging || systemContext.battery.acConnected)
{
batteryIcon = <BatteryCharging className={batteryClassName} />;
} else if (systemContext.battery.percent)
{
if (systemContext.battery.percent < 5)
{
batteryIcon = <BatteryWarning className={batteryClassName} />;
}
else if (systemContext.battery.percent < 15)
{
batteryIcon = <BatteryLow className={batteryClassName} />;
} else if (systemContext.battery.percent < 50)
{
batteryIcon = <BatteryMedium className={batteryClassName} />;
}
}
}
return !!systemContext?.battery.hasBattery && <div className="flex gap-2 items-center">
{batteryIcon}
<span className="font-semibold">{systemContext.battery?.percent} %</span>
</div>;
}
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 <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-primary focusable-hover ">
{accounts?.map(a => <HeaderAvatar
key={`header-avatar-${a.id}`}
id={`account-${a.id}`}
locked={a.locked}
preview={a.preview}
className={a.className}
onSelect={a.action}
/>)}
</div>;
}
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 <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<FocusContext value={focusKey}>
<div className="flex gap-2 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
<ClockStatus />
<WiFiStatus />
<BluetoothStatus />
<NotificationStatus />
{!!update && update.hasUpdate >= 1 && <UpdateStatus />}
<BatteryStatus />
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
<div className="flex gap-2">
{data.buttonElements}
{data.buttons?.map(b => <RoundButton
key={b.id}
shortcutLabel={b.shortcutLabel}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id}
external={b.external}
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
onAction={b.action}
>{b.icon}</RoundButton>)}
</div>
</FocusContext>
</div>;
}
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 (
<header
ref={ref}
className="flex items-center justify-between text-base-content"
style={{ viewTimelineName: 'header' }}
>
<FocusContext value={focusKey}>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title}
<HeaderStatusBar
key={"header-status-bar"}
buttonElements={data.buttonElements}
buttons={[
...data.buttons ?? [],
{
icon: <Settings />,
id: "header-settings-btn",
action: goToSettings,
external: true,
shortcutLabel: "Settings"
}
]} />
</FocusContext>
</header >
);
}
export function StickyHeaderUI (data: { ref: RefObject<any>; 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 <>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className={twMerge('sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15', data.className)}>
<HeaderUI focusable={!isStuck} {...data} />
{data.children}
</div>
</>;
}