feat: Implemented audio effects

This commit is contained in:
Simeon Radivoev 2026-04-01 21:20:34 +03:00
parent fe0ab3b498
commit edbc390d14
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
125 changed files with 1137 additions and 217 deletions

View file

@ -1,5 +1,5 @@
import { doesFocusableExist, FocusDetails, getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
import { useEffect } from "react";
import { useEffect, useLayoutEffect } from "react";
export function AutoFocus (data: {
parentKey?: string;
@ -8,7 +8,7 @@ export function AutoFocus (data: {
delay?: number;
})
{
useEffect(() =>
useLayoutEffect(() =>
{
let delayTimeout: number | undefined;

View file

@ -3,6 +3,7 @@ import classNames from "classnames";
import { JSX } from "react";
import { twMerge } from "tailwind-merge";
import useActiveControl from "../scripts/gamepads";
import { oneShot } from "../scripts/audio/audio";
export function GameCardSkeleton ()
{
@ -38,10 +39,15 @@ export interface GameCardParams
export default function CardElement (data: GameCardParams & InteractParams)
{
const handleAction = () =>
{
data.onAction?.();
oneShot('click');
};
const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey,
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current as any, details),
onEnterPress: () => data.onAction?.(),
onEnterPress: handleAction,
onBlur: () => data.onBlur?.(data.id),
});
const { isPointer } = useActiveControl();
@ -57,11 +63,10 @@ export default function CardElement (data: GameCardParams & InteractParams)
scrollSnapAlign: isPointer ? "center" : "none"
}}
onFocus={focusSelf}
onDoubleClick={e => data.onAction?.(e.nativeEvent)}
onClick={() =>
{
focusSelf();
data.onAction?.();
handleAction();
}}
className={twMerge(
"relative game-card light:bg-base-100 dark:bg-base-300 flex flex-col z-5 overflow-hidden transition-all duration-200 not-mobile:drop-shadow-lg cursor-pointer focusable focusable-primary focusable-hover select-none focused focused:not-control-mouse:animate-wiggle focused:not-control-mouse:bg-base-content focused:not-control-mouse:text-base-300 focused:not-control-mouse:drop-shadow-lg focused:not-control-mouse:drop-shadow-black/30 focused:not-control-mouse:scale-102 focused:not-control-mouse:z-10 group control-mouse:hover:bg-base-200 h-full [--tw-border-style:inset] border-2 border-base-content/5 backdrop-opacity-0 active:bg-base-content! active:text-base-100 active:transition-none",

View file

@ -3,7 +3,7 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { CardList, GameMetaExtra } from "./CardList";
import { GameCardFocusHandler } from "./CardElement";
import { getCollectionsQuery } from "@queries/romm";
import { Router } from "..";
import { useRouter } from "@tanstack/react-router";
export default function CollectionList (data: {
id: string,
@ -14,12 +14,13 @@ export default function CollectionList (data: {
saveChildFocus?: 'session' | 'local';
})
{
const router = useRouter();
const { data: collections } = useSuspenseQuery(getCollectionsQuery);
const handleDefaultSelect = (gameId: string) =>
{
const [source, id] = gameId.split('@');
Router.navigate({
router.navigate({
to: `/collection/$source/$id`,
params: { source, id },
search: { countHint: collections.find(c => c.id.id === id && c.id.source === source)?.game_count }

View file

@ -1,18 +1,18 @@
import { AnimatedBackground } from './AnimatedBackground';
import { FocusContext, setFocus, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { HeaderUI, StickyHeaderUI } from './Header';
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { StickyHeaderUI } from './Header';
import { GameList } from './GameList';
import { Search, Settings2 } from 'lucide-react';
import { JSX, Suspense, useEffect } from 'react';
import { JSX, Suspense } from 'react';
import Shortcuts from './Shortcuts';
import { AutoFocus } from './AutoFocus';
import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/shortcuts';
import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './CardElement';
import { HandleGoBack, useStickyDataAttr } from '../scripts/utils';
import { HandleGoBack } from '../scripts/utils';
import LoadingCardList from './LoadingCardList';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { gameQuery } from '../scripts/queries/romm';
import { useRouter } from '@tanstack/react-router';
export interface CollectionsDetailParams
{
@ -29,6 +29,7 @@ export interface CollectionsDetailParams
export function CollectionsDetail (data: CollectionsDetailParams)
{
const router = useRouter();
const builtData = useQuery({
queryKey: ['filter', data.id], queryFn: async () =>
{
@ -42,7 +43,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
preferredChildFocusKey: `${focusKey}-list`
});
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: () => HandleGoBack(router) }], [router]);
const { shortcuts } = useShortcutContext();
const handleScroll: GameCardFocusHandler = (cardId, node, details) =>

View file

@ -6,6 +6,7 @@ import { X } from "lucide-react";
import { GamePadButtonCode, Shortcut, useShortcuts } from "../scripts/shortcuts";
import { ContextDialogContext } from "../scripts/contexts";
import { FOCUS_KEYS } from "../scripts/types";
import { oneShot } from "../scripts/audio/audio";
export function ContextList (data: {
options?: DialogEntry[];
@ -34,6 +35,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
{
if (data.disabled === true) return;
data.action?.({ close: context.close, focus: focusSelf });
oneShot('click');
};
const { ref, focusSelf, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
@ -57,6 +59,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
onClick={handleAction}
data-selected={data.selected}
aria-disabled={data.disabled}
data-sound-category={"menu"}
className={
twMerge("flex cursor-pointer sm:text-sm md:text-base group-focusable scroll-m-4")}>
<FocusContext value={focusKey}>
@ -100,10 +103,10 @@ export function useContextDialog (id: string, data: { content?: JSX.Element; cla
data.onClose?.();
if (newSourceFocusKey)
{
setFocus(newSourceFocusKey);
setFocus(newSourceFocusKey, { instant: true });
} else if (sourceFocusKey)
{
setFocus(sourceFocusKey);
setFocus(sourceFocusKey, { instant: true });
}
}
@ -137,12 +140,14 @@ export function ContextDialog (data: {
const handleClose = () =>
{
data.close(false);
oneShot('closeContext');
};
useEffect(() =>
{
if (data.open)
{
focusSelf();
focusSelf({ instant: true });
oneShot('openContext');
}
}, [data.open]);

View file

@ -1,20 +1,20 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { ErrorComponentProps } from "@tanstack/react-router";
import { ErrorComponentProps, useRouter } from "@tanstack/react-router";
export default function Error (data: ErrorComponentProps)
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
useEffect(() => { focusSelf({ instant: true }); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>

View file

@ -8,6 +8,7 @@ import SvgIcon from "./SvgIcon";
import { twMerge } from "tailwind-merge";
import { useEffect } from "react";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
import { oneShot } from "../scripts/audio/audio";
function FilterCat (
data: {
@ -19,7 +20,10 @@ function FilterCat (
{
const { ref, focusSelf } = useFocusable({
focusKey: data.id,
onFocus: (l, p, details) => data.onFocus?.(data.id, ref.current, details),
onFocus: (l, p, details) =>
{
data.onFocus?.(data.id, ref.current, details);
},
onEnterPress: data.onAction
});
@ -27,7 +31,8 @@ function FilterCat (
<li
aria-selected={data.active}
ref={ref}
onClick={focusSelf}
onClick={e => focusSelf({ event: e.nativeEvent })}
data-sound-category={data.active ? undefined : "filter"}
className={"sm:text-sm sm:px-2 flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg focusable focusable-primary hover:not-focused:not-aria-selected:bg-base-content/40 not-focused:cursor-pointer aria-selected:bg-base-content aria-selected:text-base-300 aria-selected:drop-shadow aria-selected:cursor-default active:bg-accent! active:text-accent-content! active:ring-offset-7 active:ring-offset-base-content select-none gap-1"}
>
{data.icon ? <><div className="sm:portrait:px-2">{data.icon}</div><div className="sm:portrait:hidden md:inline">{data.children ?? data.label}</div></> : <div>{data.children ?? data.label}</div>}
@ -68,6 +73,10 @@ export function FilterUI (data: {
if (!data.options[newFilter].selected)
{
data.setSelected(newFilter);
oneShot('selectFilter');
} else
{
oneShot('invalidNavigation');
}
},
button: GamePadButtonCode.R1
@ -80,7 +89,13 @@ export function FilterUI (data: {
const selectedFilterIndex = Math.max(0, filterIndex - 1,);
const newFilter = filterKeys[selectedFilterIndex];
if (!data.options[newFilter].selected)
{
data.setSelected(newFilter);
oneShot('selectFilter');
} else
{
oneShot('invalidNavigation');
}
},
button: GamePadButtonCode.L1
}], [data.options]);
@ -90,7 +105,7 @@ export function FilterUI (data: {
{
if (hasFocusedChild)
{
setFocus(`${data.id}-${defaultFocus}`);
setFocus(`${data.id}-${defaultFocus}`, { instant: true });
}
}, [hasFocusedChild, defaultFocus, data.id]);

View file

@ -1,15 +1,16 @@
import { RPC_URL } from "@/shared/constants";
import CardElement from "./CardElement";
import { Router } from "..";
import { FileQuestion, HardDrive, Store } from "lucide-react";
import { JSX } from "react";
import { FOCUS_KEYS } from "../scripts/types";
import { useRouter } from "@tanstack/react-router";
export default function FrontEndGameCard (data: { index: number, game: FrontEndGameType; showSource?: boolean; } & FocusParams & InteractParams)
{
const router = useRouter();
function handleDefaultSelect (id: FrontEndId, source: string | null, sourceId: string | null)
{
Router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } });
};
const platformUrl = new URL(`${RPC_URL(__HOST__)}${data.game.path_platform_cover}`);

View file

@ -14,7 +14,6 @@ import
Bell,
Bluetooth,
Clock,
Plug,
Settings,
Wifi,
WifiHigh,
@ -23,17 +22,15 @@ import
} from "lucide-react";
import { RoundButton } from "./RoundButton";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { RPC_URL, SystemInfoType } from "../../shared/constants";
import { JSX, RefObject, useContext, useEffect, useRef, useState } from "react";
import { systemApi } from "../scripts/clientApi";
import { Router } from "..";
import { useStickyDataAttr } from "../scripts/utils";
import { twMerge } from "tailwind-merge";
import { TwitchIcon } from "../scripts/brandIcons";
import { rommLoggedInQuery, rommUserQuery } from "../scripts/queries/romm";
import { rommLoggedInQuery } from "../scripts/queries/romm";
import { twitchLoginVerificationQuery } from "../scripts/queries/settings";
import { da } from "zod/v4/locales";
import { SystemInfoContext } from "../scripts/contexts";
import { useRouter } from "@tanstack/react-router";
import { oneShot } from "../scripts/audio/audio";
function HeaderAvatar (data: {
id: string;
@ -206,19 +203,23 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
placeholderData: keepPreviousData
});
const { ref } = useFocusable({ focusKey: 'accounts' });
const handleSelect = () =>
{
router.navigate({ to: '/settings/accounts' });
oneShot('click');
};
const { ref } = useFocusable({
focusKey: 'accounts', onEnterPress: handleSelect
});
const accounts: HeaderAccount[] = [];
if (data.accounts) accounts.push(...data.accounts);
const router = useRouter();
if (rommUser.data?.hasLogin || rommUser.isError)
{
accounts.push({
id: 'romm', preview: `https://romm.app/_ipx/q_80/images/blocks/logos/romm.svg`,
action: () =>
{
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
},
className: rommUser.data?.hasLogin && !rommUser.isError ? undefined : "border-error",
type: 'secondary'
});
@ -228,15 +229,11 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
{
accounts.push({
id: 'twitch', preview: TwitchIcon,
action: () =>
{
Router.navigate({ to: '/settings/accounts', search: { focus: 'rommAddress' } });
},
type: 'secondary'
});
}
return <div ref={ref} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-hover ">
return <div onClick={handleSelect} ref={ref} style={{ viewTimelineName: "header-accounts" }} className="avatar-group cursor-pointer -space-x-6 w-fit flex items-center gap-2 drop-shadow-sm overflow-visible rounded-3xl focusable focusable-hover ">
{accounts?.map(a => <HeaderAvatar
key={`header-avatar-${a.id}`}
id={`account-${a.id}`}
@ -285,9 +282,10 @@ interface HeaderUIParams
export function HeaderUI (data: HeaderUIParams)
{
const { ref, focusKey } = useFocusable({ focusKey: "header-elements", focusable: data.focusable, preferredChildFocusKey: data.preferredChildFocusKey });
const router = useRouter();
const goToSettings = () =>
{
Router.navigate({ to: '/settings/accounts' });
router.navigate({ to: '/settings/accounts' });
};
return (
<FocusContext.Provider value={focusKey}>
@ -296,7 +294,7 @@ export function HeaderUI (data: HeaderUIParams)
className="flex items-center justify-between text-base-content"
style={{ viewTimelineName: 'header' }}
>
<HeaderAccounts accounts={data.accounts} />
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title}
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
</header>

View file

@ -13,6 +13,7 @@ export default function LoadMoreButton (data: { isFetching: boolean; lastId?: Fr
};
const { ref, focusKey, focused } = useFocusable({
focusable: !data.isFetching,
focusKey: 'load-more-btn',
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
onEnterPress: handleAction

View file

@ -1,19 +1,20 @@
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Home, TriangleAlert } from "lucide-react";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "../scripts/shortcuts";
import { Router } from "..";
import Shortcuts from "./Shortcuts";
import { Button } from "./options/Button";
import { useEffect } from "react";
import { useRouter } from "@tanstack/react-router";
export default function NotFound ()
{
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "not-found" });
const handleReturn = () => Router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
const router = useRouter();
const handleReturn = () => router.navigate({ to: '/', viewTransition: { types: ['zoom-in'] } });
useShortcuts(focusKey, () => [{ label: "Return Home", button: GamePadButtonCode.B, action: handleReturn }]);
const { shortcuts } = useShortcutContext();
useEffect(() => { focusSelf(); }, []);
useEffect(() => { focusSelf({ instant: true }); }, []);
return <div ref={ref} className="absolute flex flex-col justify-center items-center w-full h-full gap-4">
<FocusContext value={focusKey}>

View file

@ -83,7 +83,7 @@ export default function Screenshots (data: { screenshots?: string[]; className?:
const closest = findClosestElementToCenter(scrollRef.current);
if (!closest) return;
const closestIndex = Array.from(scrollRef.current.children).indexOf(closest);
setFocus(`screenshot-${closestIndex}`);
setFocus(`screenshot-${closestIndex}`, { instant: true });
}
}, [focused, hasFocusedChild, scrollRef.current]);

View file

@ -9,8 +9,7 @@ import MainActions from "./MainActions";
import ActionButton from "./ActionButton";
import { useLocalStorage } from "usehooks-ts";
import FocusTooltip from "../FocusTooltip";
import { Router } from "@/mainview";
import { useBlocker } from "@tanstack/react-router";
import { useBlocker, useRouter } from "@tanstack/react-router";
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
{
@ -35,11 +34,12 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
const { ref, focusKey, hasFocusedChild } = useFocusable({ focusKey: 'actions', forceFocus: true, trackChildren: true, preferredChildFocusKey: 'mainAction' });
const router = useRouter();
const deleteMutation = useMutation({
...deleteGameMutation({ id: data.id, source: data.source }),
onSuccess: (d, v, r, ctx) =>
{
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => Router.history.back());
ctx.client.invalidateQueries(gameInvalidationQuery(data.id, data.source)).then(() => router.history.back());
},
onError (error)
{

View file

@ -1,4 +1,3 @@
import { Router } from "@/mainview";
import { rommApi } from "@/mainview/scripts/clientApi";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { JSX, useEffect, useRef, useState } from "react";
@ -9,10 +8,12 @@ import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
import { Clock, Download, EllipsisVertical, Import, PackageOpen, Play, TriangleAlert } from "lucide-react";
import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview/scripts/queries/romm";
import ActionButton from "./ActionButton";
import { useRouter } from "@tanstack/react-router";
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
{
const installMut = useMutation(installMutation(data.source, data.id));
const router = useRouter();
const playMut = useMutation({
...playMutation, onError (error)
{
@ -20,7 +21,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
},
onSuccess (data, { source, id }, onMutateResult, context)
{
Router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true });
router.navigate({ to: '/launcher/$source/$id', params: { source: source, id: id }, replace: true });
},
});
const ws = useRef<{ send: (data: string) => void; }>(undefined);
@ -58,10 +59,10 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
{
if (localId)
{
Router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true });
router.navigate({ to: '/game/$source/$id', params: { id: String(localId), source: 'local' }, replace: true });
} else
{
Router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true });
router.navigate({ to: '/game/$source/$id', params: { id: data.id, source: data.source }, replace: true });
}
});
} else if (e.data.status === 'error')
@ -78,7 +79,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
sub.close();
ws.current = undefined;
};
}, [data.source, data.id]);
}, [data.source, data.id, router]);
let progressIcon: JSX.Element | undefined = undefined;
switch (status)
@ -107,7 +108,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
if (cmd.emulator === 'EMULATORJS')
{
const params = new URLSearchParams(cmd.command);
Router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true });
router.navigate({ to: '/embedded/$source/$id', params: { source: data.source, id: data.id }, search: Object.fromEntries(params.entries()), replace: true });
} else
{
playMut.mutate({ source: data.source, id: data.id, command_id: cmd.id });
@ -142,7 +143,7 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
{
if (status === 'missing-emulator')
{
Router.navigate({ to: '/settings/directories' });
router.navigate({ to: '/settings/directories' });
}
}}
id="mainAction">

View file

@ -7,6 +7,7 @@ import
import classNames from "classnames";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { CSSProperties } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio";
export type ButtonStyle = 'base' | 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error';
@ -35,9 +36,14 @@ export function Button (data: {
tooltipType?: "base" | "accent" | "error";
} & InteractParams & FocusParams)
{
const handleAction = (e?: any) =>
{
data.onAction?.(e);
oneShot('click');
};
const { ref, focused, focusKey } = useFocusable({
focusKey: data.id,
onEnterPress: data.onAction,
onEnterPress: () => handleAction(),
onFocus: (_l, _p, details) => data.onFocus?.(focusKey, ref.current, details),
focusable: !data.disabled
});
@ -49,7 +55,7 @@ export function Button (data: {
return <button
ref={ref}
onClick={e => data.onAction?.(e.nativeEvent)}
onClick={handleAction}
disabled={data.disabled}
data-tooltip={data.tooltip}
data-tooltip_type={data.tooltipType}

View file

@ -4,6 +4,7 @@ import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { ContextDialog, ContextList, DialogEntry } from "../ContextDialog";
import { ChevronDown } from "lucide-react";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { oneShot } from "@/mainview/scripts/audio/audio";
export function OptionDropdown (data: {
name: string;
@ -23,6 +24,7 @@ export function OptionDropdown (data: {
const handlePress = () =>
{
setOpen(true);
oneShot('click');
};
const handleClose = () => setOpen(false);
const { ref } = useFocusable({
@ -33,11 +35,7 @@ export function OptionDropdown (data: {
<>
<label ref={ref} className={twMerge("flex group-focusable items-center gap-3 rounded-full sm:flex-2 md:flex-1 divide-accent")}>
{!!data.icon && <span className={"text-base-content/80 is-focused:text-primary-content"}>{data.icon}</span>}
<button onClick={() =>
{
console.log("Open");
setOpen(true);
}} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300'}>{data.value}<ChevronDown /></button>
<button onClick={handlePress} className={'flex items-center justify-center border h-10 border-base-content/30 px-4 py-2 rounded-full cursor-pointer grow not-in-focused:bg-base-200 focusable focusable-accent hover:border-base-content hover:bg-base-content hover:text-base-300 active:bg-primary active:text-primary-content active:border-0'}>{data.value}<ChevronDown /></button>
</label>
{open && <ContextDialog id={`${data.name}-context`} preferredChildFocusKey={FOCUS_KEYS.CONTEXT_DIALOG_OPTION(`${data.name}-context`, String(data.values.indexOf(data.value ?? '')))} open={true} close={handleClose}>
<ContextList options={data.values.map((v, i) => ({

View file

@ -4,6 +4,7 @@ import { useOptionContext } from "./OptionSpace";
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { systemApi } from "../../scripts/clientApi";
import { CheckIcon, X } from "lucide-react";
import { oneShot } from "@/mainview/scripts/audio/audio";
export function OptionInput (data: {
name: string;
@ -27,6 +28,7 @@ export function OptionInput (data: {
{
inputRef.current?.focus();
}
oneShot('click');
};
const { ref } = useFocusable({
focusKey: data.name, onEnterPress: handlePress
@ -79,12 +81,14 @@ export function OptionInput (data: {
name={data.name}
checked={Boolean(data.value)}
type={data.type}
onClick={() => { oneShot("click"); }}
autoComplete={data.autocomplete}
onFocus={handleFocus}
placeholder={data.placeholder}
onChange={e => data.onChange?.(e.target.checked)}
onBlur={data.onBlur}
className={twMerge(
"active:bg-base-content rounded-full",
data.className
)}
/>

View file

@ -80,7 +80,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
const handleCloseSeatch = () =>
{
setIsBrowsing(false);
setFocus(`${data.id}-browse`);
setFocus(`${data.id}-browse`, { instant: true });
};
const handleInputBlur = () =>

View file

@ -8,12 +8,12 @@ import { ChevronRight, Joystick } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { scrollIntoNearestParent, useDragScroll } from "@/mainview/scripts/utils";
import FocusDots from "../FocusDots";
import { Router } from "@/mainview";
import { StoreEmulatorCard } from "./StoreEmulatorCard";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import Carousel from "../Carousel";
import { useRouter } from "@tanstack/react-router";
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant: boolean; }) => void; })
function SeeAllCard (data: { id: string; onAction: () => void; onFocus?: (details: { node: HTMLElement, instant?: boolean; }) => void; })
{
const { ref, focusKey } = useFocusable({
focusKey: data.id,
@ -39,6 +39,7 @@ export function EmulatorsSection (data: {
header?: any;
} & FocusParams)
{
const router = useRouter();
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_SECTION(data.id),
trackChildren: true,
@ -68,7 +69,7 @@ export function EmulatorsSection (data: {
scrollIntoNearestParent(node, { behavior: details.instant ? 'instant' : 'smooth' });
}} />
)) ?? Array.from({ length: 8 }).map((_, i) => <div key={i} className="skeleton h-38 w-full rounded-4xl" />)}
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => Router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
<SeeAllCard id={`${FOCUS_KEYS.EMULATOR_SECTION}-see-all`} onAction={() => router.navigate({ to: '/store/tab/emulators', viewTransition: { types: ['zoom-in'] } })} onFocus={({ node, instant }) => scrollIntoNearestParent(node, { behavior: instant ? 'instant' : 'smooth' })} />
</Carousel>
</section>

View file

@ -30,7 +30,7 @@ export function GamesSection (data: {
useEffect(() =>
{
if (focused)
focusSelf();
focusSelf({ instant: true });
}, [!!data.games]);
return (

View file

@ -9,6 +9,7 @@ import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
import { RPC_URL } from "@/shared/constants";
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { oneShot } from "@/mainview/scripts/audio/audio";
// ── Single missing-emulator card ───────────────────────────────────────────
interface MissingCardProps
@ -19,7 +20,11 @@ interface MissingCardProps
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
{
const handleSelect = () => onSelect?.(em.name, focusKey);
const handleSelect = () =>
{
onSelect?.(em.name, focusKey);
oneShot('click');
};
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.MISSING_CARD(em.name),

View file

@ -9,6 +9,7 @@ import { BadgeCheck, ChevronRight, EllipsisVertical, FileQuestion, IceCream2, Pa
import { FOCUS_KEYS } from "@/mainview/scripts/types";
import { FlatpackIcon } from "@/mainview/scripts/brandIcons";
import { JSX } from "react";
import { oneShot } from "@/mainview/scripts/audio/audio";
export const emulatorStatusIcons: Record<string, JSX.Element> = {
store: <Store />,
@ -26,7 +27,11 @@ export function StoreEmulatorCard (data: {
className?: string;
})
{
const handleSelect = () => data.onSelect?.(data.emulator.name, focusKey);
const handleSelect = () =>
{
data.onSelect?.(data.emulator.name, focusKey);
oneShot('click');
};
const { ref, focusKey } = useFocusable({
focusKey: FOCUS_KEYS.EMULATOR_CARD(data.id),
@ -45,6 +50,7 @@ export function StoreEmulatorCard (data: {
ref={ref}
role="button"
tabIndex={0}
data-sound-category="emulator"
data-installed={data.emulator.validSources.some(s => s.exists)}
onClick={isTouch ? handleSelect : undefined}
className={twMerge("relative focusable focusable-info bg-base-100 rounded-4xl transition-shadow focused:not-control-mouse:animate-scale-small shadow-lg border border-base-content/10 active:ring-4 active:ring-base-content active:transition-none", data.className)}
@ -87,7 +93,7 @@ export function StoreEmulatorCard (data: {
</div>;
})}
{isMouse && <>
<Button onAction={handleSelect} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
<Button onAction={e => data.onSelect?.(data.emulator.name, focusKey)} style="base" className="grow text-base-content/40" id={`${data.emulator.name}-details`} >Details<ChevronRight /></Button>
</>}
</div>