feat: Moved to stream zip downloading.
feat: Implemented Shortcuts. feat: Ensured it works on steam deck
This commit is contained in:
parent
f15bf9a1e0
commit
62f16cbcc1
45 changed files with 1415 additions and 631 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
54
src/mainview/components/CollectionList.tsx
Normal file
54
src/mainview/components/CollectionList.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 }))}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
38
src/mainview/components/Notifications.tsx
Normal file
38
src/mainview/components/Notifications.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue