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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { Route as rootRouteImport } from './../routes/__root'
|
||||
import { Route as SettingsRouteRouteImport } from './../routes/settings/route'
|
||||
import { Route as IndexRouteImport } from './../routes/index'
|
||||
import { Route as SettingsEmulatorsRouteImport } from './../routes/settings/emulators'
|
||||
import { Route as SettingsDirectoriesRouteImport } from './../routes/settings/directories'
|
||||
import { Route as SettingsAccountsRouteImport } from './../routes/settings/accounts'
|
||||
import { Route as SettingsAboutRouteImport } from './../routes/settings/about'
|
||||
|
|
@ -29,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsEmulatorsRoute = SettingsEmulatorsRouteImport.update({
|
||||
id: '/emulators',
|
||||
path: '/emulators',
|
||||
getParentRoute: () => SettingsRouteRoute,
|
||||
} as any)
|
||||
const SettingsDirectoriesRoute = SettingsDirectoriesRouteImport.update({
|
||||
id: '/directories',
|
||||
path: '/directories',
|
||||
|
|
@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
|
|||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -83,6 +90,7 @@ export interface FileRoutesByTo {
|
|||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -95,6 +103,7 @@ export interface FileRoutesById {
|
|||
'/settings/about': typeof SettingsAboutRoute
|
||||
'/settings/accounts': typeof SettingsAccountsRoute
|
||||
'/settings/directories': typeof SettingsDirectoriesRoute
|
||||
'/settings/emulators': typeof SettingsEmulatorsRoute
|
||||
'/game/$source/$id': typeof GameSourceIdRoute
|
||||
'/launcher/$source/$id': typeof LauncherSourceIdRoute
|
||||
'/platform/$source/$id': typeof PlatformSourceIdRoute
|
||||
|
|
@ -108,6 +117,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -119,6 +129,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -130,6 +141,7 @@ export interface FileRouteTypes {
|
|||
| '/settings/about'
|
||||
| '/settings/accounts'
|
||||
| '/settings/directories'
|
||||
| '/settings/emulators'
|
||||
| '/game/$source/$id'
|
||||
| '/launcher/$source/$id'
|
||||
| '/platform/$source/$id'
|
||||
|
|
@ -160,6 +172,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/emulators': {
|
||||
id: '/settings/emulators'
|
||||
path: '/emulators'
|
||||
fullPath: '/settings/emulators'
|
||||
preLoaderRoute: typeof SettingsEmulatorsRouteImport
|
||||
parentRoute: typeof SettingsRouteRoute
|
||||
}
|
||||
'/settings/directories': {
|
||||
id: '/settings/directories'
|
||||
path: '/directories'
|
||||
|
|
@ -216,12 +235,14 @@ interface SettingsRouteRouteChildren {
|
|||
SettingsAboutRoute: typeof SettingsAboutRoute
|
||||
SettingsAccountsRoute: typeof SettingsAccountsRoute
|
||||
SettingsDirectoriesRoute: typeof SettingsDirectoriesRoute
|
||||
SettingsEmulatorsRoute: typeof SettingsEmulatorsRoute
|
||||
}
|
||||
|
||||
const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
|
||||
SettingsAboutRoute: SettingsAboutRoute,
|
||||
SettingsAccountsRoute: SettingsAccountsRoute,
|
||||
SettingsDirectoriesRoute: SettingsDirectoriesRoute,
|
||||
SettingsEmulatorsRoute: SettingsEmulatorsRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteRouteWithChildren = SettingsRouteRoute._addFileChildren(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
|||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { RouterContext } from "..";
|
||||
import Notifications from "../components/Notifications";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
|
|
@ -12,6 +14,8 @@ function RootComponent ()
|
|||
return (
|
||||
<div className="w-screen h-screen overflow-hidden">
|
||||
<Outlet />
|
||||
<Notifications />
|
||||
<Toaster containerStyle={{ viewTimelineName: 'toasters' }} />
|
||||
{import.meta.env.DEV &&
|
||||
<>
|
||||
<TanStackRouterDevtools position="top-left" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useEventListener, useSessionStorage } from 'usehooks-ts';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useSessionStorage } from 'usehooks-ts';
|
||||
import { CollectionsDetail } from '../components/CollectionsDetail';
|
||||
import { getRomsApiRomsGetOptions } from '../../clients/romm/@tanstack/react-query.gen';
|
||||
import { DefaultRommStaleTime } from '../../shared/constants';
|
||||
|
|
@ -19,8 +19,6 @@ function RouteComponent ()
|
|||
"home-background",
|
||||
undefined,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
useEventListener("cancel", () => navigate({ to: "/", viewTransition: { types: ["zoom-out"] } }));
|
||||
|
||||
return (
|
||||
<CollectionsDetail setBackground={setBackground} filters={{ collectionId: Number(id) }} />
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/r
|
|||
import { Router } from "../..";
|
||||
import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog";
|
||||
import Shortcuts from "../../components/Shortcuts";
|
||||
|
||||
const placeholderText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eleifend ante magna, id euismod quam tempus sit amet. Maecenas sem lectus, euismod imperdiet volutpat ac, posuere in turpis. Vestibulum commodo lacinia lectus sit amet ultricies. Integer euismod consequat elit, sit amet dapibus libero fermentum nec. Aliquam accumsan placerat dui a maximus. Nunc lectus urna, scelerisque a magna non, imperdiet lobortis turpis. Aliquam magna dui, porttitor in nisl vitae, pretium fringilla sem. ";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
|
||||
const gameQuery = (source: string, id: number) => queryOptions({
|
||||
queryKey: ['game', source, id],
|
||||
|
|
@ -50,53 +49,10 @@ function GameDetailsUIPending ()
|
|||
</AnimatedBackground>;
|
||||
}
|
||||
|
||||
export function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEventListener("cancel", (e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
HandleGoBack();
|
||||
}, ref);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
|
||||
<div className="z-0 overflow-y-scroll">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: "Play" }]} />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function HandleGoBack ()
|
||||
{
|
||||
Router.navigate({ to: PopSource('details') ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
const source = PopSource('details');
|
||||
Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } });
|
||||
}
|
||||
|
||||
function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?: FrontEndGameTypeDetailed; })
|
||||
|
|
@ -153,7 +109,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
{data.game?.source ?? data.game?.id.source}
|
||||
{data.game?.local && <small className="text-base-content/60 font-semibold">local</small>}</Detail>
|
||||
</div>
|
||||
<p className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
<div className="text-base-content/80 leading-relaxed grow text-wrap whitespace-break-spaces text-ellipsis overflow-hidden">
|
||||
{data.game?.summary ?? <div className="flex flex-col gap-4 w-full">
|
||||
<div className="skeleton h-4 w-[30%]"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
|
|
@ -162,7 +118,7 @@ function Details (data: { mainAreaRef: RefObject<HTMLDivElement | null>, game?:
|
|||
<div className="skeleton h-4 w-full"></div>
|
||||
<div className="skeleton h-4 w-[80%]"></div>
|
||||
</div>}
|
||||
</p>
|
||||
</div>
|
||||
{!!data.game && <ActionButtons key="actions" game={data.game} />}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -277,6 +233,15 @@ function MainActions (data: { game: FrontEndGameTypeDetailed; })
|
|||
location.reload();
|
||||
});
|
||||
|
||||
es.addEventListener('error', (e) =>
|
||||
{
|
||||
if ((e as any).data)
|
||||
{
|
||||
const stats = JSON.parse((e as any).data) as GameInstallProgress;
|
||||
toast.error(stats.error);
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = (event) =>
|
||||
{
|
||||
const error = (event as any).data?.error;
|
||||
|
|
@ -415,7 +380,7 @@ function ActionButtons (data: { game: FrontEndGameTypeDetailed; })
|
|||
error: 'bg-error text-error-content'
|
||||
};
|
||||
|
||||
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 h-32 items-center">
|
||||
return <div ref={ref} className="flex overflow-hidden p-2 gap-4 min-h-32 items-center">
|
||||
<FocusContext value={focusKey}>
|
||||
<MainActions game={data.game} />
|
||||
<AchievementsInfo game={data.game} />
|
||||
|
|
@ -487,4 +452,45 @@ function ActionButton (data: {
|
|||
{data.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameDetailsUI ()
|
||||
{
|
||||
const { source, id } = Route.useParams();
|
||||
const { data, isSuccess } = useQuery(gameQuery(source, Number(id)));
|
||||
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" });
|
||||
const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined;
|
||||
const mainAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isSuccess)
|
||||
{
|
||||
focusSelf();
|
||||
}
|
||||
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<AnimatedBackground ref={ref} backgroundKey="game-details" backgroundUrl={backgroundImage}>
|
||||
<div className="z-0 overflow-y-scroll">
|
||||
<FocusContext value={focusKey}>
|
||||
<div className="px-3 py-2" ref={mainAreaRef}>
|
||||
<HeaderUI />
|
||||
<Details mainAreaRef={mainAreaRef} game={data} />
|
||||
</div>
|
||||
<div className="divider"><div className="flex items-center gap-3 opacity-60"><Image className="size-6" />Screenshots</div></div>
|
||||
{!!data && <Screenshots screenshots={data.paths_screenshots} />}
|
||||
<footer className="absolute left-0 bottom-0 w-full p-2 flex items-center justify-between z-10">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</FocusContext>
|
||||
</div>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ import
|
|||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import classNames from "classnames";
|
||||
import { DefaultRommStaleTime, RPC_URL } from "../../shared/constants";
|
||||
import { useEventListener, useLocalStorage } from "usehooks-ts";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import
|
||||
{
|
||||
getCollectionsApiCollectionsGetOptions,
|
||||
|
|
@ -43,10 +43,14 @@ import { twMerge } from "tailwind-merge";
|
|||
import Shortcuts from "../components/Shortcuts";
|
||||
import { PlatformsList } from "../components/PlatformsList";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
|
||||
import z from "zod";
|
||||
import { Router } from "..";
|
||||
import CollectionList from "../components/CollectionList";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: ConsoleHomeUI,
|
||||
|
||||
validateSearch: z.object({ filter: z.string().optional().default('games') })
|
||||
});
|
||||
|
||||
const filters = {
|
||||
|
|
@ -61,47 +65,6 @@ const filters = {
|
|||
},
|
||||
};
|
||||
|
||||
function CollectionList (data: { id: string, setBackground: (url: string) => void; className?: string; })
|
||||
{
|
||||
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) =>
|
||||
{
|
||||
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
|
||||
}}
|
||||
onGameFocus={(id) =>
|
||||
{
|
||||
data.setBackground(
|
||||
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeListError (data: { focused: boolean; })
|
||||
{
|
||||
const error = useErrorBoundary();
|
||||
|
|
@ -112,19 +75,26 @@ function HomeListError (data: { focused: boolean; })
|
|||
}
|
||||
|
||||
function HomeList (data: {
|
||||
selectedFilter: keyof typeof filters;
|
||||
selectedFilter: string;
|
||||
})
|
||||
{
|
||||
const [initFocus, setInitFocus] = useState(false);
|
||||
const bg = useContext(AnimatedBackgroundContext);
|
||||
const { ref, focused, focusKey, focusSelf } = useFocusable({
|
||||
focusKey: "home-list",
|
||||
preferredChildFocusKey: `${data.selectedFilter}-list`
|
||||
});
|
||||
|
||||
const lists = {
|
||||
consoles: <PlatformsList className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
const handleNodeFocus = (node: HTMLElement) =>
|
||||
{
|
||||
node.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
|
||||
setInitFocus(true);
|
||||
};
|
||||
|
||||
const lists: Record<string, JSX.Element> = {
|
||||
consoles: <PlatformsList onFocus={handleNodeFocus} className="animate-slide-up" key="consoles-list" id="consoles-list" setBackground={bg.setBackground} />,
|
||||
games: <GameList onFocus={handleNodeFocus} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} />,
|
||||
collections: <CollectionList onFocus={handleNodeFocus} className="animate-slide-up" key="collections-list" id="collections-list" setBackground={bg.setBackground} />,
|
||||
};
|
||||
|
||||
useEventListener('wheel', e =>
|
||||
|
|
@ -169,64 +139,6 @@ function HomeList (data: {
|
|||
);
|
||||
}
|
||||
|
||||
export default function ConsoleHomeUI ()
|
||||
{
|
||||
const [selectedFilter, setSelectedFilter] = useLocalStorage<
|
||||
keyof typeof filters
|
||||
>("home-filter-selected", "games");
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
saveLastFocusedChild: false,
|
||||
focusKey: "Home",
|
||||
preferredChildFocusKey: `home-list`,
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background">
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI buttons={[
|
||||
{ id: "search", icon: <Search /> },
|
||||
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex w-full flex-col grow justify-evenly">
|
||||
<FilterUI
|
||||
id="home"
|
||||
options={filters}
|
||||
selected={selectedFilter}
|
||||
setSelected={setSelectedFilter as any}
|
||||
/>
|
||||
<div className="-mb-1">
|
||||
<HomeList
|
||||
selectedFilter={selectedFilter}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MainMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="px-2 pb-2 flex items-center justify-between">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={[{ icon: 'steamdeck_button_a', label: 'Select' }]} />
|
||||
</footer>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
||||
function MainMenu (data: {})
|
||||
{
|
||||
const { ref, focusKey, hasFocusedChild } = useFocusable({
|
||||
|
|
@ -234,7 +146,6 @@ function MainMenu (data: {})
|
|||
trackChildren: true,
|
||||
onBlur: (layout, props, details) => { },
|
||||
});
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<ul
|
||||
|
|
@ -278,10 +189,11 @@ function CircleIcon (data: {
|
|||
icon?: JSX.Element;
|
||||
})
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
const { ref, focused, focusKey } = useFocusable({
|
||||
focusKey: `navigation-icon-${data.label}`,
|
||||
onEnterPress: data.action,
|
||||
});
|
||||
useShortcuts(focusKey, () => [{ label: data.label, action: (e) => data.action?.(), button: GamePadButtonCode.A }]);
|
||||
const typeClasses = {
|
||||
secondary: "bg-secondary text-secondary-content",
|
||||
accent: "bg-accent text-accent-content",
|
||||
|
|
@ -304,4 +216,85 @@ function CircleIcon (data: {
|
|||
{data.icon}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsoleHomeUI ()
|
||||
{
|
||||
const { filter } = Route.useSearch();
|
||||
|
||||
const closeMutation = useMutation({
|
||||
mutationKey: ['close'], mutationFn: async () =>
|
||||
{
|
||||
const { error } = await systemApi.api.system.exit.post();
|
||||
if (error) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
forceFocus: true,
|
||||
autoRestoreFocus: false,
|
||||
saveLastFocusedChild: false,
|
||||
focusKey: "Home",
|
||||
preferredChildFocusKey: `home-list`,
|
||||
});
|
||||
|
||||
const setFilter = (filter: string) => Router.navigate({ to: '/', search: { filter } });
|
||||
|
||||
useShortcuts(focusKey, () => [
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
|
||||
const selectedFilterIndex = Math.min(filterIndex + 1, filterKeys.length - 1);
|
||||
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
|
||||
},
|
||||
button: GamePadButtonCode.R1
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
{
|
||||
const filterKeys = Object.keys(filters);
|
||||
const filterIndex = Math.max(0, filterKeys.indexOf(filter));
|
||||
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
|
||||
Router.navigate({ to: '/', search: { filter: filterKeys[selectedFilterIndex] } });
|
||||
},
|
||||
button: GamePadButtonCode.L1
|
||||
}], [filter]);
|
||||
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
return (
|
||||
<AnimatedBackground animated ref={ref} backgroundKey="home-background">
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div className="px-3 w-full pt-2">
|
||||
<HeaderUI buttons={[
|
||||
{ id: "search", icon: <Search /> },
|
||||
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
|
||||
]} />
|
||||
</div>
|
||||
<div className="flex w-full flex-col grow justify-evenly">
|
||||
<FilterUI
|
||||
id="home"
|
||||
options={filters}
|
||||
selected={filter ? filter : 'games'}
|
||||
setSelected={setFilter}
|
||||
/>
|
||||
<div className="-mb-1">
|
||||
<HomeList
|
||||
selectedFilter={filter}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MainMenu />
|
||||
</div>
|
||||
</div>
|
||||
<footer className="px-2 pb-2 flex items-center justify-between h-12">
|
||||
<div className="flex gap-2 text-sm">
|
||||
</div>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</footer>
|
||||
</FocusContext.Provider>
|
||||
</AnimatedBackground>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { rommApi, systemApi } from '@/mainview/scripts/clientApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
|
||||
export const Route = createFileRoute('/settings/about')({
|
||||
component: RouteComponent,
|
||||
|
|
@ -50,6 +51,10 @@ function RouteComponent ()
|
|||
<th>Machine</th>
|
||||
<td>{systemInfo?.data?.machine}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Space</th>
|
||||
<td>{!!systemInfo?.data && `${prettyBytes(systemInfo?.data?.freeSpace)} Free / ${prettyBytes(systemInfo?.data?.totalSpace)} Total | ${(1 - (systemInfo?.data?.freeSpace / systemInfo?.data?.totalSpace)).toLocaleString('en-GB', { style: "percent" })}`}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Steam Deck</th>
|
||||
<td>{systemInfo?.data?.steamDeck ?? 'false'}</td>
|
||||
|
|
|
|||
|
|
@ -1,224 +1,17 @@
|
|||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { SettingsOption } from '../../components/options/SettingsOption';
|
||||
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||
import { OptionInput } from '../../components/options/OptionInput';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { settingsApi } from '../../scripts/clientApi';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '../../components/options/Button';
|
||||
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { RPC_URL } from '../../../shared/constants';
|
||||
import emulators from '@emulators';
|
||||
|
||||
export const Route = createFileRoute('/settings/directories')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: EmulatorsPending,
|
||||
});
|
||||
|
||||
function EmulatorsPending ()
|
||||
{
|
||||
return <div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
|
||||
return <ul className='flex gap-1' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
|
||||
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
|
||||
)}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function EmulatorListType (data: { category: string, action: (e: string) => void, })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
|
||||
return <div ref={ref} className='grow'>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
|
||||
id: e,
|
||||
action: (ctx) =>
|
||||
{
|
||||
data.action(e);
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary',
|
||||
content: e
|
||||
} satisfies DialogEntry))} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewEmulatorPath (data: {})
|
||||
{
|
||||
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
|
||||
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
|
||||
const handleCloseContext = () =>
|
||||
{
|
||||
setNewEmulatorTypeOpen(false);
|
||||
setFocus('emulator');
|
||||
};
|
||||
const addOverrideMutation = useMutation({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
});
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
<Button disabled={addOverrideMutation.isPending} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
|
||||
<div className='flex flex-col'>
|
||||
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
|
||||
<div className="divider mb-1 mt-2"></div>
|
||||
<EmulatorListType category={newEmulatorContextCat} action={e =>
|
||||
{
|
||||
addOverrideMutation.mutate(e);
|
||||
}} />
|
||||
</div>
|
||||
</ContextDialog>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function EmulatorPath (data: { id: string; })
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const { data: remoteValue } = useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["emulator", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue ?? '');
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
||||
<div className='flex gap-2'>
|
||||
<OptionInput
|
||||
name={data.id ?? ""}
|
||||
type="text"
|
||||
onBlur={handleSave}
|
||||
autocomplete="off"
|
||||
defaultValue={remoteValue}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function EmulatorBadge (data: { path?: string, exists: boolean, emulator: string; pathCover?: string; })
|
||||
{
|
||||
const { ref, focused } = useFocusable({
|
||||
focusKey: `badge-${data.emulator}`, onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<div ref={ref} className={
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
|
||||
classNames({
|
||||
"bg-base-200/50": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": focused
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorBadges (data: { path?: string; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
const { data: customEmulators } = useQuery({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
|
|
@ -228,15 +21,6 @@ function RouteComponent ()
|
|||
</div>
|
||||
</div>
|
||||
<SettingsOption label="Download Path" id="downloadPath" type="text" />
|
||||
<div className="divider text-2xl mt-0 md:mt-4">
|
||||
<div className="flex flex-col">
|
||||
<h3>Emulatos</h3>
|
||||
</div>
|
||||
</div>
|
||||
<EmulatorBadges />
|
||||
<div className="divider text-base-content/40">Overrides</div>
|
||||
<NewEmulatorPath />
|
||||
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
||||
</ul>
|
||||
</FocusContext>;
|
||||
}
|
||||
|
|
|
|||
246
src/mainview/routes/settings/emulators.tsx
Normal file
246
src/mainview/routes/settings/emulators.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { OptionSpace } from '../../components/options/OptionSpace';
|
||||
import { OptionInput } from '../../components/options/OptionInput';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { settingsApi } from '../../scripts/clientApi';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '../../components/options/Button';
|
||||
import { Check, ChevronDown, SearchAlert, Trash, TriangleAlert } from 'lucide-react';
|
||||
import { ContextDialog, ContextList, DialogEntry, OptionElement } from '../../components/ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { RPC_URL } from '../../../shared/constants';
|
||||
import emulators from '@emulators';
|
||||
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { GamePadButtonCode, useShortcuts } from '@/mainview/scripts/shortcuts';
|
||||
|
||||
export const Route = createFileRoute('/settings/emulators')({
|
||||
component: RouteComponent,
|
||||
pendingComponent: EmulatorsPending,
|
||||
});
|
||||
|
||||
function EmulatorsPending ()
|
||||
{
|
||||
return <div className="flex flex-col p-2 px-3 w-full h-full">
|
||||
<div className="flex flex-col justify-center items-center grow">
|
||||
<span className="loading loading-dots loading-xl"></span>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorListCat (data: { selected: string, set: (c: string) => void; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'categories' });
|
||||
return <ul className='flex gap-1' ref={ref}>
|
||||
<FocusContext value={focusKey}>
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTVWXYZ"].map(c =>
|
||||
<OptionElement key={c} className={classNames('p-2 justify-center size-8 text-base-content bg-base-300 text-lg', { "ring-4 ring-primary": data.selected === c })} onFocus={() => data.set(c)} content={c} id={c} action={(ctx) => ctx.focus()} type="primary" />
|
||||
)}
|
||||
</FocusContext>
|
||||
</ul>;
|
||||
}
|
||||
|
||||
function EmulatorListType (data: { category: string, action: (e: string) => void, })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: 'list-section' });
|
||||
return <div ref={ref} className='grow'>
|
||||
<FocusContext value={focusKey}>
|
||||
<ContextList className='h-[60vh]' options={Object.keys(emulators).filter(e => e.startsWith(data.category)).map(e => ({
|
||||
id: e,
|
||||
action: (ctx) =>
|
||||
{
|
||||
data.action(e);
|
||||
ctx.close();
|
||||
},
|
||||
type: 'primary',
|
||||
content: e
|
||||
} satisfies DialogEntry))} />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function NewEmulatorPath (data: { addOverride: (emulator: string) => void; isAddingOverride: boolean; })
|
||||
{
|
||||
const [newEmulatorTypeOpen, setNewEmulatorTypeOpen] = useState(false);
|
||||
const [newEmulatorContextCat, setNewEmulatorContextCat] = useState('A');
|
||||
const handleCloseContext = () =>
|
||||
{
|
||||
setNewEmulatorTypeOpen(false);
|
||||
setFocus('emulator');
|
||||
};
|
||||
|
||||
|
||||
return <OptionSpace label={"Custom Emulator Path"}>
|
||||
<Button disabled={data.isAddingOverride} id='emulator' type='button' onAction={() => setNewEmulatorTypeOpen(true)} >
|
||||
Emulator
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<ContextDialog open={newEmulatorTypeOpen} id='new-emulator-type-context' close={handleCloseContext}>
|
||||
<div className='flex flex-col'>
|
||||
<EmulatorListCat selected={newEmulatorContextCat} set={setNewEmulatorContextCat} />
|
||||
<div className="divider mb-1 mt-2"></div>
|
||||
<EmulatorListType category={newEmulatorContextCat} action={e =>
|
||||
{
|
||||
data.addOverride(e);
|
||||
}} />
|
||||
</div>
|
||||
</ContextDialog>
|
||||
</OptionSpace>;
|
||||
}
|
||||
|
||||
function EmulatorPath (data: { id: string; })
|
||||
{
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const { data: remoteValue } = useQuery({
|
||||
enabled: !!data.id,
|
||||
queryKey: ["emulator", data.id],
|
||||
queryFn: async () =>
|
||||
{
|
||||
const { data: value, error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).get();
|
||||
if (error) throw error;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
const setSettingMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'set'],
|
||||
mutationFn: async (value: string) => settingsApi.api.settings.emulators.custom({ id: data.id }).put({ value }),
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ["emulator", data.id] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["emulator", data.id, 'delete'],
|
||||
mutationFn: async () =>
|
||||
{
|
||||
const { error } = await settingsApi.api.settings.emulators.custom({ id: data.id }).delete();
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) =>
|
||||
{
|
||||
ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] });
|
||||
ctx.client.invalidateQueries({ queryKey: ["auto-emulators"] });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() =>
|
||||
{
|
||||
if (dirty)
|
||||
{
|
||||
setDirty(false);
|
||||
setSettingMutation.mutate(localValue ?? '');
|
||||
}
|
||||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace label={<><p className='font-semibold'>{data.id}</p><small className='text-base-content/40'>{emulators[data.id]}</small></>}>
|
||||
<div className='flex gap-2'>
|
||||
<OptionInput
|
||||
name={data.id ?? ""}
|
||||
type="text"
|
||||
onBlur={handleSave}
|
||||
autocomplete="off"
|
||||
defaultValue={remoteValue}
|
||||
onChange={(e) =>
|
||||
{
|
||||
setLocalValue(e.currentTarget.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
<Button id={`delete-${data.id}`} className='p-2' onAction={() => deleteMutation.mutate()} type='button' >
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</OptionSpace>
|
||||
);
|
||||
}
|
||||
|
||||
function EmulatorBadge (data: {
|
||||
path?: string,
|
||||
exists: boolean,
|
||||
emulator: string;
|
||||
pathCover?: string;
|
||||
addOverride: (emulator: string) => void;
|
||||
})
|
||||
{
|
||||
const { focusKey, ref, focused } = useFocusable({
|
||||
focusKey: `badge-${data.emulator}`, onFocus: () =>
|
||||
{
|
||||
(ref.current as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: 'Add Override', button: GamePadButtonCode.A, action: () =>
|
||||
data.addOverride(data.emulator)
|
||||
}], [data.addOverride]);
|
||||
|
||||
return <div className={classNames("tooltip tooltip-primary", { "tooltip-open": focused })} data-tip={`${emulators[data.emulator]}`}>
|
||||
<div ref={ref} className={
|
||||
twMerge('flex flex-col rounded-3xl bg-base-300 w-64 h-16 justify-center items-center p-4 overflow-hidden',
|
||||
classNames({
|
||||
"bg-base-200/50": !data.path,
|
||||
"border-dashed border-base-content/40 border-2": focused
|
||||
|
||||
}))
|
||||
}>
|
||||
<p className='flex gap-2 font-semibold'>
|
||||
{data.path ? data.exists ? <Check /> : <TriangleAlert className='text-error' /> : <SearchAlert className='text-warning' />}
|
||||
{!!data.pathCover && <img className='size-6 drop-shadow drop-shadow-black/20' src={`${RPC_URL(__HOST__)}${data.pathCover}`}></img>}
|
||||
{data.emulator}
|
||||
</p>
|
||||
{data.path ? <small className={classNames('opacity-60 max-w-full overflow-clip text-nowrap text-ellipsis', { 'text-error': !data.exists })}>{data.path}</small> : ""}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function EmulatorBadges (data: { path?: string; addOverride: (emulator: string) => void; })
|
||||
{
|
||||
const { data: autoEmulators } = useQuery({ queryKey: ['auto-emulators'], queryFn: async () => settingsApi.api.settings.emulators.automatic.get() });
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `emulator-badges`, focusable: !!autoEmulators?.data && autoEmulators.data.length > 0 });
|
||||
return <div ref={ref} className='flex flex-wrap gap-2 justify-center-safe'>
|
||||
<FocusContext value={focusKey}>
|
||||
{autoEmulators?.data?.map(e => <EmulatorBadge key={e.emulator} addOverride={data.addOverride} pathCover={e.path_cover ?? undefined} path={e.path} exists={e.exists} emulator={e.emulator} />)}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RouteComponent ()
|
||||
{
|
||||
const { focus } = Route.useSearch();
|
||||
const { ref, focusKey, focusSelf } = useFocusable({
|
||||
preferredChildFocusKey: focus
|
||||
});
|
||||
|
||||
const { data: customEmulators } = useQuery({
|
||||
queryKey: ['custom-emulators'], queryFn: async () =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const addOverrideMutation = useMutation({
|
||||
mutationKey: ['emulator', 'custom', 'add'],
|
||||
mutationFn: async (id: string) =>
|
||||
{
|
||||
const { data, error } = await settingsApi.api.settings.emulators.custom({ id }).put({ value: '' });
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (d, v, r, ctx) => ctx.client.invalidateQueries({ queryKey: ['custom-emulators'] })
|
||||
});
|
||||
|
||||
return <FocusContext value={focusKey}>
|
||||
<ul ref={ref} className="list rounded-box gap-2">
|
||||
<EmulatorBadges addOverride={addOverrideMutation.mutate} />
|
||||
<div className="divider text-base-content/40">Overrides</div>
|
||||
<NewEmulatorPath isAddingOverride={addOverrideMutation.isPending} addOverride={addOverrideMutation.mutate} />
|
||||
{!!customEmulators && customEmulators.map((key) => <EmulatorPath key={key} id={key} />)}
|
||||
</ul>
|
||||
</FocusContext>;
|
||||
}
|
||||
|
|
@ -6,12 +6,11 @@ import
|
|||
import
|
||||
{
|
||||
Outlet,
|
||||
Link,
|
||||
createFileRoute,
|
||||
useMatchRoute,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { retainSearchParams, ViewTransitionOptions } from "@tanstack/router-core";
|
||||
import { ViewTransitionOptions } from "@tanstack/router-core";
|
||||
import classNames from "classnames";
|
||||
import
|
||||
{
|
||||
|
|
@ -19,16 +18,17 @@ import
|
|||
FingerprintPattern,
|
||||
HardDrive,
|
||||
Info,
|
||||
Joystick,
|
||||
MonitorCog,
|
||||
} from "lucide-react";
|
||||
import { JSX, useEffect, useRef } from "react";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import ShortcutPrompt from "../../components/ShortcutPrompt";
|
||||
import { JSX, useEffect } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import z from "zod";
|
||||
import { SettingsSchema } from "../../../shared/constants";
|
||||
import { PopSource } from "../../scripts/spatialNavigation";
|
||||
import { Router } from "../..";
|
||||
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import Shortcuts from "@/mainview/components/Shortcuts";
|
||||
|
||||
export const Route = createFileRoute("/settings")({
|
||||
component: SettingsUI,
|
||||
|
|
@ -123,6 +123,12 @@ function SettingsMenu (data: {})
|
|||
label="Visual"
|
||||
icon={<MonitorCog />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/emulators"
|
||||
label="Emulators"
|
||||
icon={<Joystick />}
|
||||
/>
|
||||
<MenuItem
|
||||
focusSelect
|
||||
route="/settings/directories"
|
||||
|
|
@ -172,12 +178,14 @@ export function SettingsUI ()
|
|||
preferredChildFocusKey: 'settings-menu'
|
||||
});
|
||||
|
||||
useEventListener("cancel", HandleGoBack, ref);
|
||||
useEffect(() =>
|
||||
{
|
||||
focusSelf();
|
||||
}, []);
|
||||
|
||||
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
|
||||
const { shortcuts } = useShortcutContext();
|
||||
|
||||
return (
|
||||
<FocusContext.Provider value={focusKey}>
|
||||
<div ref={ref} className="flex flex-col w-full h-full p-4 bg-base-100">
|
||||
|
|
@ -191,11 +199,7 @@ export function SettingsUI ()
|
|||
</div>
|
||||
</div>
|
||||
<div className="divider divider-end">
|
||||
<ShortcutPrompt
|
||||
onClick={HandleGoBack}
|
||||
icon="steamdeck_button_b"
|
||||
label="Back"
|
||||
/>
|
||||
<Shortcuts shortcuts={shortcuts} />
|
||||
</div>
|
||||
</div>
|
||||
</FocusContext.Provider>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation";
|
|||
|
||||
let loopStarted = false;
|
||||
|
||||
|
||||
window.addEventListener("gamepadconnected", (evt) =>
|
||||
{
|
||||
if (!loopStarted)
|
||||
|
|
@ -11,6 +12,7 @@ window.addEventListener("gamepadconnected", (evt) =>
|
|||
loopStarted = true;
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("gamepaddisconnected", (evt) =>
|
||||
{
|
||||
|
||||
|
|
@ -45,7 +47,20 @@ window.addEventListener('keydown', e =>
|
|||
}
|
||||
});
|
||||
|
||||
export class GamepadButtonEvent extends Event
|
||||
{
|
||||
button: number;
|
||||
gamepad?: Gamepad;
|
||||
isClick: boolean;
|
||||
|
||||
constructor(type: string, init: EventInit & { button: number, gamepad?: Gamepad; isClick?: boolean; })
|
||||
{
|
||||
super(type, init);
|
||||
this.button = init.button;
|
||||
this.gamepad = init.gamepad;
|
||||
this.isClick = init.isClick ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus ()
|
||||
{
|
||||
|
|
@ -53,32 +68,25 @@ function updateStatus ()
|
|||
{
|
||||
const gamepadEvent = new GamepadEvent('gamepad-navigation', { gamepad, });
|
||||
|
||||
if (gamepad.buttons[0].pressed)
|
||||
for (let i = 0; i < gamepad.buttons.length; i++)
|
||||
{
|
||||
if (!throttleMap.has('enter'))
|
||||
{
|
||||
dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }), window);
|
||||
throttleMap.set('enter', 0);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (throttleMap.delete('enter'))
|
||||
{
|
||||
dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
|
||||
}
|
||||
}
|
||||
const button = gamepad.buttons[i];
|
||||
const key = String(i);
|
||||
|
||||
if (gamepad.buttons[1].pressed)
|
||||
{
|
||||
if (!throttleMap.has('cancel'))
|
||||
if (button.pressed)
|
||||
{
|
||||
const evn = new Event('cancel', { bubbles: true, cancelable: true });
|
||||
dispatchFocusedEvent(evn);
|
||||
throttleMap.set('cancel', 0);
|
||||
if (!throttleMap.has(key))
|
||||
{
|
||||
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad }));
|
||||
throttleMap.set(key, 0);
|
||||
}
|
||||
} else
|
||||
{
|
||||
if (throttleMap.delete(key))
|
||||
{
|
||||
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad }));
|
||||
}
|
||||
}
|
||||
} else
|
||||
{
|
||||
throttleMap.delete('cancel');
|
||||
}
|
||||
|
||||
const activeFocus = GetFocusedElement(getCurrentFocusKey());
|
||||
|
|
|
|||
137
src/mainview/scripts/shortcuts.ts
Normal file
137
src/mainview/scripts/shortcuts.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { DependencyList, useEffect, useState } from "react";
|
||||
import { GamepadButtonEvent } from "./gamepads";
|
||||
import { dispatchFocusedEvent, GetFocusedTree } from "./spatialNavigation";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
|
||||
const shortcutMap = new Map<string, Shortcut[]>();
|
||||
const conflictSet = new Set<number>();
|
||||
let hadEnterDown = false;
|
||||
|
||||
export enum GamePadButtonCode
|
||||
{
|
||||
A = 0,
|
||||
B = 1,
|
||||
X = 2,
|
||||
Y = 3,
|
||||
L1 = 4,
|
||||
R1 = 5,
|
||||
L2 = 6,
|
||||
R2 = 7,
|
||||
Select = 8,
|
||||
Start = 9,
|
||||
LJoy = 10,
|
||||
RJoy = 11,
|
||||
Up = 12,
|
||||
Down = 13,
|
||||
Left = 14,
|
||||
Right = 15,
|
||||
Steam = 16
|
||||
}
|
||||
|
||||
export interface Shortcut
|
||||
{
|
||||
label?: string;
|
||||
button: GamePadButtonCode;
|
||||
action: (e: GamepadButtonEvent) => void;
|
||||
}
|
||||
|
||||
export function useShortcutContext ()
|
||||
{
|
||||
const [array, setArray] = useState<Shortcut[] | undefined>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const handleShortcutRebuild = () =>
|
||||
{
|
||||
conflictSet.clear();
|
||||
const newArray = GetFocusedTree(getCurrentFocusKey())
|
||||
.filter(f => shortcutMap.has(f))
|
||||
.flatMap(f => shortcutMap.get(f)!)
|
||||
.filter(s =>
|
||||
{
|
||||
const empty = !conflictSet.has(s.button);
|
||||
conflictSet.add(s.button);
|
||||
return empty;
|
||||
});
|
||||
if (!compareShortcutArrays(newArray, array))
|
||||
{
|
||||
setArray(newArray);
|
||||
}
|
||||
};
|
||||
|
||||
const shortcuts = new Map(array?.reverse().map(s => [s.button, s]) ?? []);
|
||||
const handleGamepadButtonDown = (e: Event) =>
|
||||
{
|
||||
const event = e as GamepadButtonEvent;
|
||||
if (shortcuts.has(event.button))
|
||||
{
|
||||
shortcuts.get(event.button)?.action(event);
|
||||
}
|
||||
else if (event.button === GamePadButtonCode.A)
|
||||
{
|
||||
dispatchFocusedEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
|
||||
hadEnterDown = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGamepadButtonUp = (e: Event) =>
|
||||
{
|
||||
const event = e as GamepadButtonEvent;
|
||||
if (hadEnterDown && event.button === GamePadButtonCode.A)
|
||||
{
|
||||
dispatchFocusedEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
function compareShortcut (a: Shortcut, b: Shortcut)
|
||||
{
|
||||
return a.action === b.action && a.button === b.button && a.label === b.label;
|
||||
}
|
||||
|
||||
function compareShortcutArrays (a: Shortcut[] | undefined, b: Shortcut[] | undefined)
|
||||
{
|
||||
if (a === b) return true;
|
||||
if (a === undefined || b === undefined) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++)
|
||||
{
|
||||
if (!compareShortcut(a[i], b[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!array)
|
||||
{
|
||||
handleShortcutRebuild();
|
||||
}
|
||||
window.addEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
||||
window.addEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
||||
window.addEventListener('focuschanged', handleShortcutRebuild);
|
||||
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener('focuschanged', handleShortcutRebuild);
|
||||
window.removeEventListener('gamepadbuttondown', handleGamepadButtonDown);
|
||||
window.removeEventListener('gamepadbuttonup', handleGamepadButtonUp);
|
||||
};
|
||||
}, [array]);
|
||||
|
||||
return { shortcuts: array };
|
||||
}
|
||||
|
||||
export function useShortcuts (focusKey: string, build: () => Shortcut[], ...deps: DependencyList)
|
||||
{
|
||||
useEffect(() =>
|
||||
{
|
||||
shortcutMap.set(focusKey, build());
|
||||
|
||||
return () =>
|
||||
{
|
||||
shortcutMap.delete(focusKey);
|
||||
};
|
||||
}, [...deps, focusKey]);
|
||||
|
||||
}
|
||||
|
|
@ -17,12 +17,17 @@ let setCurrentFocusedKey = SpatialNavigation.setCurrentFocusedKey.bind(SpatialNa
|
|||
|
||||
type SaveFocusType = "session" | "local";
|
||||
|
||||
type HistorySourceType = "settings" | 'details' | 'launch';
|
||||
type HistorySourceType = "settings" | 'details' | 'launch' | 'game-list';
|
||||
const historySourceMap = new Map<string, string>();
|
||||
|
||||
export function SaveSource (id: HistorySourceType, url?: string)
|
||||
{
|
||||
historySourceMap.set(id, url ?? location.hash.replace("#", ''));
|
||||
const finalUrl = url ?? location.hash.replace("#", '');
|
||||
if (finalUrl)
|
||||
{
|
||||
historySourceMap.set(id, finalUrl);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function HasSource (id: HistorySourceType)
|
||||
|
|
@ -46,6 +51,27 @@ export function GetFocusedElement (focusKey: string)
|
|||
return (SpatialNavigation as any).focusableComponents[focusKey]?.node as HTMLElement;
|
||||
}
|
||||
|
||||
export function GetFocusedTree (leaf: string): string[]
|
||||
{
|
||||
const tree: string[] = [];
|
||||
let component = (SpatialNavigation as any).focusableComponents[leaf];
|
||||
while (component)
|
||||
{
|
||||
tree.push(component.focusKey);
|
||||
|
||||
if (component.parentFocusKey && !tree.includes(component.parentFocusKey))
|
||||
{
|
||||
component = (SpatialNavigation as any).focusableComponents[component.parentFocusKey];
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function dispatchFocusedEvent (event: Event, override?: Element | Window)
|
||||
{
|
||||
const focusedElement = GetFocusedElement(getCurrentFocusKey());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue