feat: Bundled NW.js with appimages

feat: Implemented self update
feat: Added rclone saves for emulators
fix: Fixed auto focus in builds
feat: Added helper cards on empty library
This commit is contained in:
Simeon Radivoev 2026-04-26 03:26:15 +03:00
parent 587956c792
commit 813785f4f3
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
59 changed files with 1210 additions and 480 deletions

View file

@ -25,17 +25,13 @@ export function AnimatedBackground (data: {
)
: useState<string | undefined>();
const [lastBackgroundUrl, setLastBackgroundUrl] = useState<string | undefined>(undefined);
const backgroundElementRef = useRef<HTMLDivElement>(null);
useEffect(() =>
{
const lastBg = backgroundUrl;
if (data.backgroundUrl != backgroundUrl)
{
setBackgroundUrl(data.backgroundUrl ? (data.backgroundUrl instanceof URL ? data.backgroundUrl.href : data.backgroundUrl) : undefined);
setLastBackgroundUrl(lastBg);
}
}, [data.backgroundUrl]);
@ -44,13 +40,6 @@ export function AnimatedBackground (data: {
{
finalBackgroundUrl = backgroundUrl ? new URL(backgroundUrl) : undefined;
} catch { }
let finalLastBackgroundUrl: URL | undefined;
try
{
finalLastBackgroundUrl = lastBackgroundUrl ? new URL(lastBackgroundUrl) : undefined;
} catch { }
const blur = useLocalSetting('backgroundBlur');
if (blur)
{
@ -59,13 +48,7 @@ export function AnimatedBackground (data: {
finalBackgroundUrl?.searchParams.set('blur', String(24));
}
if (!finalLastBackgroundUrl?.searchParams.has('blur'))
{
finalLastBackgroundUrl?.searchParams.set('blur', String(24));
}
finalBackgroundUrl?.searchParams.set('height', String(320));
finalLastBackgroundUrl?.searchParams.set('height', String(320));
}
useEffect(() =>
@ -90,8 +73,6 @@ export function AnimatedBackground (data: {
function handleSetBackground (url: string)
{
setLastBackgroundUrl(backgroundUrl);
setBackgroundUrl(url);
}
@ -120,7 +101,7 @@ export function AnimatedBackground (data: {
>
{!data.scrolling && <div className='absolute top-0 left-0 right-0 bottom-0 overflow-hidden'>
<div className='absolute w-full h-full bg-radial from-base-100 to-base-300 -z-5'></div>
{blur && finalLastBackgroundUrl && <img className='absolute w-full h-full object-cover object-center -z-4 mask-radial-at-center mask-radial-from-0 mask-radial-farthest-corner' src={finalLastBackgroundUrl.href}></img>}
{finalBackgroundUrl ? <img
decoding="async"
key={finalBackgroundUrl?.href}

View file

@ -37,6 +37,10 @@ export default function AppCommunication (data: { children: any; })
});
document.documentElement.dataset.loaded = "true";
return () =>
{
sub.close();
};
}, []);
return <SystemInfoContext value={systemInfo}>

View file

@ -1,5 +1,5 @@
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useLayoutEffect } from "react";
import { useEffect, useLayoutEffect } from "react";
export function AutoFocus (data: {
parentKey?: string;
@ -8,11 +8,15 @@ export function AutoFocus (data: {
delay?: number;
})
{
useLayoutEffect(() =>
useEffect(() =>
{
let delayTimeout: number | undefined;
if (data.force || !getCurrentFocusKey() || getCurrentFocusKey() === data.parentKey || !doesFocusableExist(getCurrentFocusKey()))
const focusDoesntExist = !doesFocusableExist(getCurrentFocusKey());
const parentFocus = getCurrentFocusKey() === data.parentKey;
const noFocus = !getCurrentFocusKey();
if (data.force || noFocus || parentFocus || focusDoesntExist)
{
if (data.delay)
{
@ -21,8 +25,8 @@ export function AutoFocus (data: {
{
data.focus({ instant: true });
}
}
return () =>
{
if (delayTimeout)

View file

@ -32,7 +32,7 @@ function LocalCardElement (data: { game: GameMetaExtra, i: number; } & FocusPara
oneShot('click');
};
useShortcuts(data.game.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
useShortcuts(data.game.focusKey, () => [{ label: "Details", button: GamePadButtonCode.A, action: event => handleAction({ event, focusKey: data.game.focusKey }) }]);
return (
<CardElement
@ -69,7 +69,7 @@ export function CardList (data: {
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
focusable: data.games.length > 0,
focusable: data.games.length > 0 || (!!data.finalElement && (Array.isArray(data.finalElement) ? data.finalElement.length > 0 : !!data.finalElement)),
preferredChildFocusKey: data.focus
});

View file

@ -30,7 +30,7 @@ import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { SystemInfoContext } from "../scripts/contexts";
import { useRouter } from "@tanstack/react-router";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio";
import { hasUpdateQuery } from "../scripts/queries/system";
@ -87,16 +87,24 @@ export interface HeaderAccount
function UpdateStatus ()
{
const handleSelect = () =>
{
navigate({ to: '/settings/about' });
};
const hasUnread = false;
return <div className={classNames("tooltip tooltip-bottom tooltip-warning p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })} data-tip="Update Available">
<CircleFadingArrowUp className="sm:size-4 md:size-8 text-warning" />
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", { "bg-warning text-warning-content": hasUnread })}>
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>;
}
@ -219,14 +227,17 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
router.navigate({ to: '/settings/accounts' });
oneShot('click');
};
const { ref } = useFocusable({
focusKey: 'accounts', onEnterPress: handleSelect
});
const accounts: HeaderAccount[] = [];
if (data.accounts) accounts.push(...data.accounts);
const router = useRouter();
const { ref } = useFocusable({
focusKey: 'accounts',
onEnterPress: handleSelect,
focusable: accounts.length > 0
});
if (rommUser.data?.hasLogin || rommUser.isError)
{
accounts.push({
@ -259,7 +270,7 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
{
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
const { data: hasUpdate } = useQuery(hasUpdateQuery);
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' }}>
@ -267,7 +278,7 @@ export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElement
<WiFiStatus />
<BluetoothStatus />
<NotificationStatus />
{!!hasUpdate && hasUpdate >= 1 && <UpdateStatus />}
{!!update && update.hasUpdate >= 1 && <UpdateStatus />}
<BatteryStatus />
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}

View file

@ -13,7 +13,20 @@ export default function ImageWithFallbacks (data: {
{
img.dataset.index = String(nextIndex);
img.src = data.src[nextIndex].href;
}
};
return <img draggable={data.draggable} className={data.className} src={data.src[0].href} data-index={0} onError={handleError}></img>;
return <img
draggable={data.draggable}
className={data.className}
src={data.src[0].href}
data-index={0}
onError={handleError}
onLoad={e =>
{
e.currentTarget.dataset.loaded = "true";
}}
>
</img>;
}

View file

@ -1,8 +1,9 @@
import { Ref, RefObject } from 'react';
import './dots.css';
export default function DotsLoading ()
export default function DotsLoading (data: { ref?: Ref<any>; })
{
return <div className="flex gap-3 justify-center animation_alternate items-center pt-8">
return <div ref={data.ref} className="flex gap-3 justify-center animation_alternate items-center pt-8">
<div className="ball size-6"></div>
<div className="ball size-6"></div>
<div className="ball size-6"></div>

View file

@ -109,7 +109,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (!cmd) return;
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
const params = new URLSearchParams(Array.isArray(cmd.command) ? cmd.command[0] : cmd.command);
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()) });
} else
{
@ -120,14 +120,15 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
let mainButton: any | undefined = undefined;
if (status === 'installed')
{
mainButton = <div className="flex gap-2"><ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
key="primary"
type='primary'
id="mainAction"
>
<Play />
mainButton = <div className="flex gap-2">
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
key="primary"
type='primary'
id="mainAction"
>
<Play />
</ActionButton>
</ActionButton>
{validCommands.length > 1 &&
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>