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
383 lines
No EOL
12 KiB
TypeScript
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>
|
|
</>;
|
|
} |