feat: Moved to stream zip downloading.

feat: Implemented Shortcuts.
feat: Ensured it works on steam deck
This commit is contained in:
Simeon Radivoev 2026-02-21 18:28:07 +02:00
parent f15bf9a1e0
commit 62f16cbcc1
Signed by: simeonradivoev
GPG key ID: C16C2132A7660C8E
45 changed files with 1415 additions and 631 deletions

View file

@ -3,11 +3,11 @@ import
FocusContext,
useFocusable,
} from "@noriginmedia/norigin-spatial-navigation";
import { FrontEndId, GameMeta } from "../../shared/constants";
import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardParams } from "./GameCard";
import { JSX, useState } from "react";
import classNames from "classnames";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
export interface GameMetaExtra extends GameMeta
{
@ -22,7 +22,7 @@ export function CardList (data: {
games: GameMetaExtra[];
grid?: boolean;
onSelectGame?: (id: string) => void;
onGameFocus?: (id: string) => void;
onGameFocus?: (id: string, node: HTMLElement) => void;
className?: string;
})
{
@ -30,13 +30,21 @@ export function CardList (data: {
focusKey: data.id,
});
function BuildGame (g: GameMetaExtra, i: number)
function BuildCard (g: GameMetaExtra, i: number)
{
let preview: GameCardParams['preview'] = g.preview;
if (!preview && g.previewUrl)
{
preview = g.previewUrl;
}
const handleAction = () =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
};
useShortcuts(g.focusKey, () => [{ label: "Select", button: GamePadButtonCode.A, action: handleAction }]);
return (
<GameCard
key={g.id}
@ -46,17 +54,12 @@ export function CardList (data: {
data-index={i}
title={g.title}
subtitle={g.subtitle ?? ""}
onFocus={() =>
onFocus={(id, node) =>
{
g.onFocus?.();
data.onGameFocus?.(g.id);
(document.querySelector(":root") as HTMLElement).style.setProperty('--selected-card-offset', `${i}s`);
}}
onAction={() =>
{
g.onSelect?.();
data.onSelectGame?.(g.id);
data.onGameFocus?.(id, node);
}}
onAction={handleAction}
preview={preview}
badges={g.badges}
id={g.id}
@ -82,7 +85,7 @@ export function CardList (data: {
style={{ scrollbarWidth: "none" }}
>
<FocusContext.Provider value={focusKey}>
{data.games.map(BuildGame)}
{data.games.map(BuildCard)}
</FocusContext.Provider>
</ul>
);

View file

@ -0,0 +1,54 @@
import { getCollectionsApiCollectionsGetOptions } from "@/clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, RPC_URL } from "@/shared/constants";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation";
export default function CollectionList (data: {
id: string,
setBackground: (url: string) => void;
className?: string;
onFocus?: (node: HTMLElement) => void;
})
{
const navigate = useNavigate();
const { data: collections } = useSuspenseQuery({
...getCollectionsApiCollectionsGetOptions(),
refetchOnWindowFocus: false,
staleTime: DefaultRommStaleTime
});
return (
<CardList
type="collection"
id={data.id}
className={data.className}
games={collections.sort((a, b) => Date.parse(a.updated_at) - Date.parse(b.updated_at))
.map((g) => ({
id: String(g.id),
title: g.name,
focusKey: `collection-${g.id}`,
subtitle: g.user__username,
previewUrl: `${RPC_URL(__HOST__)}/api/romm/${g.path_covers_large[0]}`,
badges: [
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
{g.rom_count}
</span>
],
} satisfies GameMetaExtra))}
onSelectGame={(id) =>
{
SaveSource('game-list');
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}}
onGameFocus={(id, node) =>
{
data.setBackground(
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
);
data.onFocus?.(node);
}}
/>
);
}

View file

@ -6,6 +6,9 @@ import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation';
export interface CollectionsDetailParams
{
@ -17,6 +20,16 @@ export interface CollectionsDetailParams
footer?: JSX.Element;
}
function HandleGoBack ()
{
const source = PopSource('game-list');
if (source)
{
console.log("Found source ", source, " to go back to");
}
Router.navigate({ to: source ?? "/", viewTransition: { types: ['zoom-out'] } });
}
export function CollectionsDetail (data: CollectionsDetailParams)
{
const focusKey = `game-list-${data.id}-${data.filters.platformId}-${data.filters.collectionId}`;
@ -25,6 +38,9 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`,
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext();
return (
<FocusContext value={focusKey}>
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
@ -44,7 +60,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
<div>
{data.footer}
</div>
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_b', label: 'Back' }]} />
<Shortcuts shortcuts={shortcuts} />
</footer>
</AnimatedBackground>
</FocusContext>

View file

@ -4,6 +4,7 @@ import { createContext, JSX, useContext, useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { useEventListener } from "usehooks-ts";
import { X } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
const ContextDialogContext = createContext({} as {
close: () => void,
@ -75,14 +76,14 @@ export function ContextDialog (data: { id: string, children: any | any[], open:
}
}, [data.open]);
useEventListener('cancel', (e) =>
{
if (data.open)
useShortcuts(focusKey, () => [{
label: "Close",
button: GamePadButtonCode.B,
action: () =>
{
e.stopPropagation();
data.close();
}
}, ref);
}], []);
return <dialog ref={ref} open={data.open} closedby="any" className={
twMerge("absolute modal cursor-pointer bg-base-300/80 backdrop-brightness-50 duration-300 ease-in-out transition-all text-base-content",

View file

@ -5,6 +5,8 @@ import
} from "@noriginmedia/norigin-spatial-navigation";
import SvgIcon from "./SvgIcon";
import classNames from "classnames";
import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
function FilterCat (
data: {
@ -12,14 +14,25 @@ function FilterCat (
children?: any;
active: boolean;
onFocus: () => void;
hasFocusedPeer: boolean;
} & FilterOption,
)
{
const { ref, focusSelf, focused } = useFocusable({
focusKey: data.id,
onFocus: data.onFocus,
onEnterPress: data.onAction,
onEnterPress: data.onAction
});
const { filter } = useSearch({ from: '/' });
useEffect(() =>
{
if (filter == data.id && data.hasFocusedPeer)
{
focusSelf();
}
}, [filter]);
return (
<li
ref={ref}
@ -46,7 +59,14 @@ export function FilterUI (data: {
setSelected: (id: string) => void;
})
{
const { ref, focusKey } = useFocusable({ focusKey: `filter-${data.id}` });
const { ref, focusKey, hasFocusedChild } = useFocusable({
focusKey: `filter-${data.id}`,
saveLastFocusedChild: false,
autoRestoreFocus: false,
preferredChildFocusKey: data.selected,
trackChildren: true
});
return (
<div
ref={ref}
@ -60,6 +80,7 @@ export function FilterUI (data: {
</li>
{Object.entries(data.options)?.map(([id, option]) => (
<FilterCat
hasFocusedPeer={hasFocusedChild}
id={id}
key={id}
onFocus={() => data.setSelected(id)}

View file

@ -27,7 +27,7 @@ export interface GameCardParams
id: string;
badges?: JSX.Element[];
className?: string;
onFocus?: (id: string) => void;
onFocus?: (id: string, node: HTMLElement) => void;
onBlur?: (id: string) => void;
onAction?: () => void;
clickFocuses?: boolean;
@ -37,23 +37,11 @@ export default function GameCard (data: GameCardParams)
{
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id),
onFocus: () => data.onFocus?.(data.id, ref.current as any),
onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id)
});
useEffect(() =>
{
if (focused)
{
(ref.current as HTMLElement).scrollIntoView({
behavior: "smooth",
inline: "center",
block: 'center'
});
}
}, [focused]);
return (
<li
id={`game-entry-${data.id}`}
@ -86,14 +74,14 @@ export default function GameCard (data: GameCardParams)
>
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}>
{typeof data.preview === "string" ? (
<img width={5192} height={5192} className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
<img className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img>
) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div>
<div className="h-0 flex pr-2 justify-end items-center">
{data.badges?.map(b =>
<div
{data.badges?.map((b, i) =>
<div key={i}
className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))}

View file

@ -1,7 +1,7 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { GameMetaExtra, CardList } from "./CardList";
import { FrontEndId, RPC_URL } from "../../shared/constants";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react";
@ -20,6 +20,7 @@ export interface GameListParams
grid?: boolean,
setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void;
onFocus?: (node: HTMLElement) => void;
className?: string;
}
@ -35,7 +36,6 @@ export function GameList (data: GameListParams)
}).then(d => d.data)
});
const navigator = useNavigate();
const location = useLocation();
const handleFocus = (id: FrontEndId) =>
{
@ -61,6 +61,7 @@ export function GameList (data: GameListParams)
type="game"
grid={data.grid}
className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)}
games={games.data?.games
.map(
(g) =>

View file

@ -18,7 +18,7 @@ export default function LoadingCardList (data: { placeholderCount: number, grid?
}}
style={{ scrollbarWidth: "none" }}
>
{new Array(data.placeholderCount).fill(1).map(p => <GameCardSkeleton />)}
{new Array(data.placeholderCount).fill(1).map((p, i) => <GameCardSkeleton key={i} />)}
</ul>
);
}

View file

@ -0,0 +1,38 @@
import { Notification, RPC_URL } from "@/shared/constants";
import { useEffect } from "react";
import toast from "react-hot-toast";
export default function Notifications (data: {})
{
useEffect(() =>
{
const es = new EventSource(`${RPC_URL(__HOST__)}/api/system/notifications`);
es.addEventListener('notification', (e) =>
{
const notification = JSON.parse(e.data) as Notification;
if (notification.type === 'error')
{
toast.error(notification.message);
} else if (notification.type === 'success')
{
toast.success(notification.message);
} else
{
toast.custom(notification.message);
}
});
es.onerror = (event) =>
{
const error = (event as any).data?.error;
if (error)
{
toast.error(error);
}
};
return () => es.close();
}, []);
return undefined;
}

View file

@ -1,12 +1,12 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { getPlatformsApiPlatformsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { DefaultRommStaleTime, GameMeta, RPC_URL } from "../../shared/constants";
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
import { CardList, GameMetaExtra } from "./CardList";
import classNames from "classnames";
import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; })
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; })
{
const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery(
@ -27,6 +27,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
type="platform"
id={data.id}
className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)}
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g) => ({
id: g.slug,
@ -42,6 +43,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
),
onSelect: () =>
{
SaveSource('game-list');
navigate({ to: `/platform/${g.source ?? g.id.source}/${g.source_id ?? g.id.id}`, viewTransition: { types: ['zoom-in'] } });
},
preview:

View file

@ -4,6 +4,7 @@ import classNames from "classnames";
import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: {
id: string;
icon: IconType;
label?: string;
className?: string;
@ -11,8 +12,9 @@ export default function ShortcutPrompt (data: {
})
{
return (
<span
<div
onClick={data.onClick}
style={{ viewTransitionName: data.id }}
className={twMerge(
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
"sm:text-sm",
@ -24,6 +26,6 @@ export default function ShortcutPrompt (data: {
>
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} />
{data.label}
</span>
</div>
);
}

View file

@ -1,18 +1,39 @@
import { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon';
export interface Shortcut
{
icon: IconType;
label: string;
action?: () => void;
}
const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.A]: 'steamdeck_button_a',
[GamePadButtonCode.B]: 'steamdeck_button_b',
[GamePadButtonCode.X]: 'steamdeck_button_x',
[GamePadButtonCode.Y]: 'steamdeck_button_y',
[GamePadButtonCode.L1]: 'steamdeck_button_l1',
[GamePadButtonCode.R1]: 'steamdeck_button_r1',
[GamePadButtonCode.L2]: 'steamdeck_button_l2',
[GamePadButtonCode.R2]: 'steamdeck_button_r2',
[GamePadButtonCode.Select]: 'steamdeck_button_guide',
[GamePadButtonCode.Start]: 'steamdeck_button_options',
[GamePadButtonCode.LJoy]: 'steamdeck_stick_l_press',
[GamePadButtonCode.RJoy]: 'steamdeck_stick_r_press',
[GamePadButtonCode.Up]: 'steamdeck_dpad_up',
[GamePadButtonCode.Down]: 'steamdeck_dpad_down',
[GamePadButtonCode.Left]: 'steamdeck_dpad_left',
[GamePadButtonCode.Right]: 'steamdeck_dpad_right',
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
};
export default function Shortcuts (data: { shortcuts: Shortcut[]; })
export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
{
return (
<div style={{ viewTransitionName: 'shortcuts' }} className="flex gap-2">
{data.shortcuts.map((s, i) => <ShortcutPrompt key={i} onClick={s.action} icon={s.icon} label={s.label} />)}
<div className="flex gap-2">
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button}
id={`shortcut-${s.button}`}
onClick={e => s.action(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={iconMap[s.button]}
label={s.label} />
)}
</div>
);
}