feat: Implemented local game import (with a wizard)
feat: Implemented a radial virtual gamepad keyboard. fix: Fixed shortcuts for file explorer
This commit is contained in:
parent
e54a6ac8f0
commit
06b7e4074d
66 changed files with 2216 additions and 416 deletions
|
|
@ -3,6 +3,7 @@ import { SystemInfoContext } from "../scripts/contexts";
|
|||
import { systemApi } from "../scripts/clientApi";
|
||||
import { SystemInfoType } from "@/shared/constants";
|
||||
import LoadingScreen from "./LoadingScreen";
|
||||
import { GamepadKeyboard } from "./GamepadKeyboard";
|
||||
|
||||
export default function AppCommunication (data: { children: any; })
|
||||
{
|
||||
|
|
@ -55,5 +56,6 @@ export default function AppCommunication (data: { children: any; })
|
|||
</div>
|
||||
</LoadingScreen>
|
||||
: data.children}
|
||||
<GamepadKeyboard />
|
||||
</SystemInfoContext>;
|
||||
}
|
||||
|
|
@ -8,10 +8,9 @@ export default function CollectionList (data: {
|
|||
id: string,
|
||||
setBackground: (url: string) => void;
|
||||
className?: string;
|
||||
onFocus?: GameCardFocusHandler;
|
||||
onSelect?: (id: string) => void;
|
||||
saveChildFocus?: 'session' | 'local';
|
||||
})
|
||||
} & FocusParams)
|
||||
{
|
||||
const router = useRouter();
|
||||
const { data: collections } = useSuspenseQuery(getCollectionsQuery);
|
||||
|
|
@ -37,7 +36,7 @@ export default function CollectionList (data: {
|
|||
id: `${g.id.source}@${g.id.id}`,
|
||||
title: g.name,
|
||||
focusKey: `collection-${g.id}`,
|
||||
previewUrl: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
||||
previewUrls: `${RPC_URL(__HOST__)}${g.path_platform_cover}`,
|
||||
badges: [
|
||||
<span className="text-lg font-bold badge bg-base-100 shadow-md shadow-base-300 h-8 rounded-full mr-2">
|
||||
{g.game_count}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,17 @@
|
|||
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
|
||||
import { HeaderButton, StickyHeaderUI } from './Header';
|
||||
import { GameList } from './GameList';
|
||||
import { ArrowDownAz, CalendarArrowDown, ClockArrowDown, Drama, Filter, FunnelX, HardDrive, Rocket, Search, Settings2, SortDesc, Store, Tags, User, UserLock } from 'lucide-react';
|
||||
import { JSX, Suspense, useRef, useState } from 'react';
|
||||
import { JSX, Suspense } from 'react';
|
||||
import { FloatingShortcuts } from './Shortcuts';
|
||||
import { AutoFocus } from './AutoFocus';
|
||||
import { GamePadButtonCode, useShortcuts } from '../scripts/shortcuts';
|
||||
import { GameListFilterSchema, GameListFilterType } from '@/shared/constants';
|
||||
import { GameListFilterType } from '@/shared/constants';
|
||||
import { HandleGoBack } from '../scripts/utils';
|
||||
import LoadingCardList from './LoadingCardList';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { gameFiltersQuery, gameQuery } from '../scripts/queries/romm';
|
||||
import { useNavigate, useRouter } from '@tanstack/react-router';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
import SelectMenu from './SelectMenu';
|
||||
import { RoundButton } from './RoundButton';
|
||||
import { ContextList, DialogEntry, useContextDialog } from './ContextDialog';
|
||||
import classNames from 'classnames';
|
||||
import { sourceIconMap } from './Constants';
|
||||
import { stat } from 'fs-extra';
|
||||
import { FilterUI } from './Filters';
|
||||
import SideFilters from './SideFilters';
|
||||
|
||||
export interface CollectionsDetailParams
|
||||
|
|
@ -75,7 +68,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
|
|||
<div className='absolute top-0 bottom-0 left-0 right-0 bg-radial from-base-100 to-base-300 -z-1'></div>
|
||||
<div className='mobile:hidden bg-noise'></div>
|
||||
<div className='mobile:hidden bg-dots'></div>
|
||||
{finalFilter && data.title}
|
||||
{!!finalFilter && data.title}
|
||||
{<Suspense fallback={<LoadingCardList grid placeholderCount={data.countHint ?? 8} id={`${focusKey}-list`} />}>
|
||||
<GameList
|
||||
key={`${data.id}-${JSON.stringify(finalFilter)}`}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function ContextList (data: {
|
|||
{
|
||||
const context = useContext(ContextDialogContext);
|
||||
return <ul className={twMerge("list gap-1", data.className)}>
|
||||
{data.options?.map((o, i) => <OptionElement className="list-row" key={i} {...o} />)}
|
||||
{data.options?.map((o, i) => <OptionElement className="list-row" key={`${o.id}-${i}`} {...o} />)}
|
||||
{data.showCloseButton !== false && <div className="divider m-0 "></div>}
|
||||
{data.showCloseButton !== false && <OptionElement disabled={data.disableCloseButton} className="list-row" type='accent' icon={<X />} action={() => context.close()} id="close-context-dialog" content="Close" />}
|
||||
</ul>;
|
||||
|
|
@ -40,7 +40,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
|
|||
};
|
||||
const { ref, focusSelf, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION(context.id, data.id),
|
||||
onEnterPress: data.shortcuts ? undefined : handleAction,
|
||||
onEnterPress: handleAction,
|
||||
onFocus: handleFocus,
|
||||
trackChildren: typeof data.content !== 'string'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { ContextList, DialogEntry } from "./ContextDialog";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { FocusEventHandler, useContext, useRef, useState } from "react";
|
||||
import path from "pathe";
|
||||
import { Check, File, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||
import { Check, File, FileInput, Folder, FolderInput, FolderOutput, FolderPlus, HardDrive, Usb, X } from "lucide-react";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DirType } from "@/shared/constants";
|
||||
import classNames from "classnames";
|
||||
|
|
@ -15,7 +14,6 @@ import toast from "react-hot-toast";
|
|||
import { FilePickerContext } from "../scripts/contexts";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { createFolderMutation, drivesQuery, filesQuery } from "@queries/system";
|
||||
import { showKeyboardHandler } from "../scripts/utils";
|
||||
|
||||
function List (data: {
|
||||
id: string,
|
||||
|
|
@ -48,7 +46,7 @@ function List (data: {
|
|||
let icon = <Folder className="text-warning" />;
|
||||
if (isDefaultPath)
|
||||
{
|
||||
icon = <FolderInput className="text-warning" />;
|
||||
icon = f.isDirectory ? <FolderInput className="text-accent" /> : <FileInput className="text-accent" />;
|
||||
} else if (!f.isDirectory)
|
||||
{
|
||||
icon = <File />;
|
||||
|
|
@ -97,7 +95,6 @@ function NewFolderInput (data: { id: string, name: string | undefined, setName:
|
|||
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) =>
|
||||
{
|
||||
focusSelf();
|
||||
showKeyboardHandler(control as any, e.target);
|
||||
};
|
||||
return <div className={data.className} ref={ref}>
|
||||
<input ref={inputRef}
|
||||
|
|
|
|||
509
src/mainview/components/GamepadKeyboard.tsx
Normal file
509
src/mainview/components/GamepadKeyboard.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
import { createRef, JSX, RefObject, useEffect, useRef, useState } from "react";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import { ArrowLeft, ArrowRight, CornerDownLeft, Delete, Space } from "lucide-react";
|
||||
import { GamePadButtonCode } from "../scripts/shortcuts";
|
||||
import { GamepadIconMap } from "./Shortcuts";
|
||||
import ShortcutPrompt from "./ShortcutPrompt";
|
||||
import { getLocalSetting, showKeyboardHandler } from "../scripts/utils";
|
||||
|
||||
const Keys = [
|
||||
['E', 'R', 'T', 'F', 'D', 'G', 'V', 'C', 'S', 'X', 'Z', 'B', 'A', 'Q', 'W'],
|
||||
['I', '⌫', 'O', '⏎', 'P', 'L', 'N', '␣', 'M', 'J', 'K', 'H', 'Y', 'U']
|
||||
];
|
||||
const Characters = [
|
||||
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "%", "$", "#", "@", "+"],
|
||||
[",", '⌫', ".", '⏎', "/", "[", "]", '␣', "(", ")", ":", "!", "?", "&"]
|
||||
];
|
||||
function GetKeys (characters: boolean)
|
||||
{
|
||||
return characters ? Characters : Keys;
|
||||
}
|
||||
const KeyColors: Record<string, { bg: string, color: string; }> = {
|
||||
'⌫': { bg: "var(--color-accent)", color: "var(--color-accent-content)" },
|
||||
'⏎': { bg: "var(--color-secondary)", color: "var(--color-secondary-content)" },
|
||||
'␣': { bg: "var(--color-info)", color: "var(--color-info-content)" },
|
||||
};
|
||||
const Shortcuts: Record<string, GamePadButtonCode> = {
|
||||
'⌫': GamePadButtonCode.X,
|
||||
'␣': GamePadButtonCode.Y,
|
||||
'⏎': GamePadButtonCode.A,
|
||||
'←': GamePadButtonCode.Left,
|
||||
'→': GamePadButtonCode.Right,
|
||||
'⇧': GamePadButtonCode.RJoy,
|
||||
'⌥': GamePadButtonCode.LJoy
|
||||
};
|
||||
const KeyElements: Record<string, JSX.Element> = {
|
||||
'⌫': <Delete />,
|
||||
'␣': <Space />,
|
||||
'⏎': <CornerDownLeft />,
|
||||
'←': <ArrowLeft />,
|
||||
'→': <ArrowRight />,
|
||||
};
|
||||
const DZ = 0.22, TH = 0.85, NS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function ang (x: number, y: number)
|
||||
{
|
||||
if (Math.sqrt(x * x + y * y) < DZ) return null;
|
||||
let a = Math.atan2(x, -y);
|
||||
if (a < 0) a += Math.PI * 2;
|
||||
return a;
|
||||
}
|
||||
|
||||
function gidx (a: number | null, n: number)
|
||||
{
|
||||
return a === null ? -1 : Math.floor(a / (Math.PI * 2) * n) % n;
|
||||
}
|
||||
|
||||
function buildWheel (side: 0 | 1, shift: boolean, characters: boolean)
|
||||
{
|
||||
const elements: JSX.Element[] = [];
|
||||
const refs: RefObject<HTMLSpanElement | null>[] = [];
|
||||
const positions: { left: string; top: string; }[] = [];
|
||||
const W = 258, C = 129, R2 = 107, R1 = 42, n = GetKeys(characters)[side].length, GAP = 0.028;
|
||||
|
||||
for (let i = 0; i < n; i++)
|
||||
{
|
||||
const a0 = i / n * Math.PI * 2 - Math.PI / 2 + GAP;
|
||||
const a1 = (i + 1) / n * Math.PI * 2 - Math.PI / 2 - GAP;
|
||||
const am = (a0 + a1) / 2;
|
||||
const ref = createRef<HTMLSpanElement>();
|
||||
const x = Math.cos(am);
|
||||
const y = Math.sin(am);
|
||||
refs.push(ref);
|
||||
|
||||
const tr = 66;
|
||||
positions.push({ left: `50% + ${tr * x}% - 16px`, top: `50% + ${tr * y}% - 16px` });
|
||||
|
||||
elements.push(<>
|
||||
<span key={GetKeys(characters)[side][i]} ref={ref} className='flex absolute bg-base-100 size-8 text-xl items-center justify-center p-1 rounded-full transition-[background,scale]' style={{
|
||||
left: `calc(50% + ${tr * x}% - 16px)`,
|
||||
top: `calc(50% + ${tr * y}% - 16px)`,
|
||||
backgroundColor: KeyColors[GetKeys(characters)[side][i]]?.bg,
|
||||
color: KeyColors[GetKeys(characters)[side][i]]?.color,
|
||||
}}>
|
||||
{KeyElements[GetKeys(characters)[side][i]] ?? shift ? GetKeys(characters)[side][i].toUpperCase() : GetKeys(characters)[side][i].toLocaleLowerCase()}
|
||||
</span>
|
||||
</>);
|
||||
}
|
||||
|
||||
return { elements, refs, positions };
|
||||
}
|
||||
|
||||
export type EditableInput = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export function typeKey (el: EditableInput, key: string): void
|
||||
{
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
|
||||
el.value =
|
||||
el.value.slice(0, start) +
|
||||
key +
|
||||
el.value.slice(end);
|
||||
|
||||
const pos = start + key.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
|
||||
export function backspace (el: EditableInput): void
|
||||
{
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
|
||||
// selection delete
|
||||
if (start !== end)
|
||||
{
|
||||
el.value =
|
||||
el.value.slice(0, start) +
|
||||
el.value.slice(end);
|
||||
|
||||
el.setSelectionRange(start, start);
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing to delete
|
||||
if (start === 0) return;
|
||||
|
||||
el.value =
|
||||
el.value.slice(0, start - 1) +
|
||||
el.value.slice(end);
|
||||
|
||||
el.setSelectionRange(start - 1, start - 1);
|
||||
}
|
||||
|
||||
export function deleteForward (el: EditableInput): void
|
||||
{
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
|
||||
if (start !== end)
|
||||
{
|
||||
el.value =
|
||||
el.value.slice(0, start) +
|
||||
el.value.slice(end);
|
||||
|
||||
el.setSelectionRange(start, start);
|
||||
return;
|
||||
}
|
||||
|
||||
if (start >= el.value.length) return;
|
||||
|
||||
el.value =
|
||||
el.value.slice(0, start) +
|
||||
el.value.slice(start + 1);
|
||||
|
||||
el.setSelectionRange(start, start);
|
||||
}
|
||||
|
||||
export function enter (el: EditableInput): void
|
||||
{
|
||||
if (el instanceof HTMLTextAreaElement)
|
||||
{
|
||||
|
||||
const start = el.selectionStart ?? 0;
|
||||
const end = el.selectionEnd ?? 0;
|
||||
|
||||
const insert = "\n";
|
||||
|
||||
el.value =
|
||||
el.value.slice(0, start) +
|
||||
insert +
|
||||
el.value.slice(end);
|
||||
|
||||
const pos = start + 1;
|
||||
el.setSelectionRange(pos, pos);
|
||||
|
||||
} else
|
||||
{
|
||||
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', charCode: 13, keyCode: 13, view: window, bubbles: true }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function arrowLeft (el: EditableInput): void
|
||||
{
|
||||
const pos = el.selectionStart ?? 0;
|
||||
const newPos = Math.max(0, pos - 1);
|
||||
|
||||
el.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
|
||||
export function arrowRight (el: EditableInput): void
|
||||
{
|
||||
const pos = el.selectionStart ?? 0;
|
||||
const newPos = Math.min(el.value.length, pos + 1);
|
||||
|
||||
el.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
|
||||
export function GamepadKeyboard ()
|
||||
{
|
||||
const triggerThreshold = 0.85;
|
||||
const [focusedInput, setFocusedInput] = useState<HTMLInputElement | null>(null);
|
||||
const circleRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
|
||||
const sideRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
|
||||
const keyIndicatorRefs = [useRef<HTMLDivElement>(null), useRef<HTMLDivElement>(null)];
|
||||
const activeControl = useActiveControl();
|
||||
const hidden = !focusedInput || activeControl.control !== 'gamepad';
|
||||
const keyboardRef = useRef<HTMLDivElement>(null);
|
||||
const [shift, setShift] = useState(false);
|
||||
const [characters, setCharacters] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!hidden)
|
||||
{
|
||||
oneShot('openKeyboard');
|
||||
}
|
||||
}, [hidden]);
|
||||
|
||||
const elements = [buildWheel(0, shift, characters), buildWheel(1, shift, characters)];
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
let disposed = false;
|
||||
const lockedIds: [number | undefined, number | undefined] = [undefined, undefined];
|
||||
const actionRepeatTimeout: [NodeJS.Timeout | undefined, NodeJS.Timeout | undefined] = [undefined, undefined];
|
||||
const actionRepeatCount = [0, 0];
|
||||
const prevTriggerValues = [0, 0];
|
||||
const buttonValues: Record<number, number> = {};
|
||||
const buttonRepeatTimeout: Record<number, NodeJS.Timeout> = {};
|
||||
const buttonRepeatCounts: Record<number, number> = {};
|
||||
const lastIndexes = [-1, -1];
|
||||
|
||||
function update ()
|
||||
{
|
||||
const gps = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
const gp = [...gps].find(g => g);
|
||||
|
||||
if (keyboardRef.current && focusedInput && !hidden)
|
||||
{
|
||||
const targetRect = focusedInput.getBoundingClientRect();
|
||||
const el = keyboardRef.current;
|
||||
|
||||
// First, measure the element itself
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
const margin = 64; // keep some space from edges
|
||||
|
||||
let left = targetRect.left;
|
||||
let top = targetRect.bottom + 128;
|
||||
|
||||
// Clamp horizontally
|
||||
if (left + elRect.width > window.innerWidth - margin)
|
||||
{
|
||||
left = window.innerWidth - elRect.width - margin;
|
||||
}
|
||||
|
||||
if (left < margin)
|
||||
{
|
||||
left = margin;
|
||||
}
|
||||
|
||||
// Clamp vertically
|
||||
if (top + elRect.height > window.innerHeight - margin)
|
||||
{
|
||||
// flip above the input if it doesn't fit below
|
||||
top = targetRect.top - elRect.height - 128;
|
||||
}
|
||||
|
||||
if (top < margin)
|
||||
{
|
||||
top = margin;
|
||||
}
|
||||
|
||||
el.style.position = "fixed";
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
if (gp && !hidden)
|
||||
{
|
||||
function pressKey (el: EditableInput, key: string, repeatCount: number): void
|
||||
{
|
||||
const hapticIntensity = 1 / Math.max(repeatCount, 1);
|
||||
const soundIntensity = 1 / Math.min(2, Math.max(repeatCount * 0.2, 1));
|
||||
gp?.vibrationActuator.playEffect('dual-rumble', { duration: 60, strongMagnitude: hapticIntensity, weakMagnitude: hapticIntensity });
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "⌫":
|
||||
oneShot('keyPressBackspace', { volume: soundIntensity });
|
||||
return backspace(el);
|
||||
case "Delete":
|
||||
oneShot('keyPressBackspace', { volume: soundIntensity });
|
||||
return deleteForward(el);
|
||||
case "←":
|
||||
oneShot('keyPress', { volume: soundIntensity });
|
||||
return arrowLeft(el);
|
||||
case "→":
|
||||
oneShot('keyPress', { volume: soundIntensity });
|
||||
return arrowRight(el);
|
||||
case "⏎":
|
||||
oneShot('keyPress', { volume: soundIntensity });
|
||||
return enter(el);
|
||||
case "␣":
|
||||
oneShot('keyPressSpace', { volume: soundIntensity });
|
||||
return typeKey(el, ' ');
|
||||
case "⇧":
|
||||
setShift(v => !v);
|
||||
return;
|
||||
case "⌥":
|
||||
setCharacters(v => !v);
|
||||
return;
|
||||
default:
|
||||
oneShot('keyPress', { volume: soundIntensity });
|
||||
return typeKey(el, shift ? key.toUpperCase() : key.toLocaleLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (let side = 0; side < 2; side++)
|
||||
{
|
||||
const x = gp.axes[side * 2] ?? 0;
|
||||
const y = gp.axes[side * 2 + 1] ?? 0;
|
||||
const triggerValue = Math.max(gp.buttons[6 + side]?.value ?? 0, gp.buttons[4 + side]?.value ?? 0);
|
||||
const angle = ang(x, y);
|
||||
const keyIndex = lockedIds[side] !== undefined ? lockedIds[side]! : gidx(angle, GetKeys(characters)[side].length);
|
||||
|
||||
elements[side].refs.filter(e => e.current).forEach((e, i) =>
|
||||
{
|
||||
const active = keyIndex === i;
|
||||
const key = GetKeys(characters)[side][i];
|
||||
const elem = e.current!;
|
||||
elem.style.backgroundColor = active ? 'var(--color-primary)' : KeyColors[key]?.bg ?? '';
|
||||
elem.style.color = active ? 'var(--color-primary-content)' : KeyColors[key]?.color ?? '';
|
||||
elem.style.scale = `${active ? 150 : 100}%`;
|
||||
elem.style.fontStyle = active ? 'bold' : 'normal';
|
||||
});
|
||||
|
||||
const circle = circleRefs[side].current!;
|
||||
|
||||
// Update actions
|
||||
if (keyIndex >= 0)
|
||||
{
|
||||
if (focusedInput)
|
||||
{
|
||||
if (triggerValue >= triggerThreshold && prevTriggerValues[side] < triggerThreshold)
|
||||
{
|
||||
const timeoutCalc = () => 400 / Math.min(4, Math.max(1, 1 + (actionRepeatCount[side] ?? 0)));
|
||||
const handleRepeat = () =>
|
||||
{
|
||||
elements[side].refs[keyIndex].current!.animate([
|
||||
{ boxShadow: "0 0 0 0 var(--color-base-content)" },
|
||||
{ boxShadow: "0 0 0 10px transparent" }
|
||||
],
|
||||
{ duration: 300, easing: 'ease-out', fill: 'none' }
|
||||
);
|
||||
pressKey(focusedInput, GetKeys(characters)[side][keyIndex], actionRepeatCount[side]);
|
||||
actionRepeatCount[side]++;
|
||||
actionRepeatTimeout[side] = setTimeout(handleRepeat, timeoutCalc());
|
||||
};
|
||||
handleRepeat();
|
||||
}
|
||||
else if (triggerValue < triggerThreshold && prevTriggerValues[side] >= triggerThreshold)
|
||||
{
|
||||
clearTimeout(actionRepeatTimeout[side]);
|
||||
actionRepeatCount[side] = -1;
|
||||
}
|
||||
|
||||
if (lockedIds[side] === undefined && triggerValue > 0.1)
|
||||
{
|
||||
lockedIds[side] = keyIndex;
|
||||
} else if (lockedIds[side] !== undefined && triggerValue <= 0.1)
|
||||
{
|
||||
lockedIds[side] = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
keyIndicatorRefs[side].current!.textContent = shift ? GetKeys(characters)[side][keyIndex].toUpperCase() : GetKeys(characters)[side][keyIndex].toLowerCase();
|
||||
} else
|
||||
{
|
||||
keyIndicatorRefs[side].current!.textContent = "";
|
||||
}
|
||||
|
||||
// Update cirlce
|
||||
const magnitudeSqr = (x * x) + (y * y);
|
||||
const magnitude = Math.sqrt(magnitudeSqr);
|
||||
|
||||
const elementPos = keyIndex < 0 ? undefined : elements[side].positions[keyIndex];
|
||||
//const lerpX = (element?.left ?? 0);
|
||||
//const lerpY = (element?.top ?? 0);
|
||||
const size = 12;
|
||||
circle.style.left = `calc(50% + ${50 * x}% - 16px)`;
|
||||
circle.style.top = `calc(50% + ${50 * y}% - 16px)`;
|
||||
circle.style.opacity = `${1 - Math.pow(magnitude, 2)}`;
|
||||
circle.style.backgroundColor = `color-mix(in srgb, var(--color-base-content), 'var(--color-primary)'} ${magnitude * 100}%)`;
|
||||
|
||||
if (sideRefs[side].current)
|
||||
{
|
||||
sideRefs[side].current!.style.background = `radial-gradient(
|
||||
circle at calc(50% + ${100 * x}px) calc(50% + ${100 * y}px),
|
||||
color-mix(in srgb, var(--color-primary) 20%, transparent),
|
||||
transparent
|
||||
)`;
|
||||
}
|
||||
|
||||
|
||||
if (lastIndexes[side] !== keyIndex)
|
||||
{
|
||||
gp.vibrationActuator.playEffect('dual-rumble', { duration: 30, strongMagnitude: 0, weakMagnitude: 0.2 });
|
||||
oneShot('keyHover');
|
||||
}
|
||||
|
||||
prevTriggerValues[side] = triggerValue;
|
||||
lastIndexes[side] = keyIndex;
|
||||
}
|
||||
|
||||
const shortcutKeys = Object.entries(Shortcuts);
|
||||
function handleButton (key: number, repeatCount: number)
|
||||
{
|
||||
if (!focusedInput) return;
|
||||
const entry = shortcutKeys.find(([n, value]) => value === key);
|
||||
if (key === GamePadButtonCode.A) return;
|
||||
if (entry)
|
||||
{
|
||||
pressKey(focusedInput, entry[0], repeatCount);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < gp.buttons.length; i++)
|
||||
{
|
||||
const btn = gp.buttons[i];
|
||||
if (btn.value >= 0.85 && buttonValues[i] < 0.85)
|
||||
{
|
||||
const timeoutCalc = () => 400 / Math.min(8, Math.max(1, 1 + (buttonRepeatCounts[i] ?? 0)));
|
||||
const handleRepeat = () =>
|
||||
{
|
||||
handleButton(i, buttonRepeatCounts[i]);
|
||||
buttonRepeatCounts[i] = (buttonRepeatCounts[i] ?? -1) + 1;
|
||||
buttonRepeatTimeout[i] = setTimeout(handleRepeat, timeoutCalc());
|
||||
};
|
||||
handleRepeat();
|
||||
}
|
||||
else if (btn.value < 0.85 && buttonValues[i] >= 0.85)
|
||||
{
|
||||
clearTimeout(buttonRepeatTimeout[i]);
|
||||
buttonRepeatCounts[i] = -1;
|
||||
}
|
||||
|
||||
buttonValues[i] = btn.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!disposed && !hidden) requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
if (!disposed && !hidden) requestAnimationFrame(update);
|
||||
|
||||
return () =>
|
||||
{
|
||||
disposed = true;
|
||||
Object.values(buttonRepeatTimeout).forEach(v => clearTimeout(v));
|
||||
Object.values(actionRepeatTimeout).forEach(v => clearTimeout(v));
|
||||
};
|
||||
}, [focusedInput, elements, shift, characters, hidden]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
const handleFocus = (e: FocusEvent) =>
|
||||
{
|
||||
if (e.target instanceof HTMLInputElement && (e.target.type === 'text' || e.target.type === 'search'))
|
||||
{
|
||||
if (!getLocalSetting('autoKeybaord')) return;
|
||||
if (getLocalSetting('useGameflowKeyboard'))
|
||||
{
|
||||
setFocusedInput(e.target);
|
||||
} else
|
||||
{
|
||||
showKeyboardHandler(activeControl.control, e.target);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: FocusEvent) =>
|
||||
{
|
||||
setFocusedInput(null);
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', handleFocus);
|
||||
document.addEventListener('focusout', handleBlur);
|
||||
|
||||
return () =>
|
||||
{
|
||||
document.removeEventListener('focusin', handleFocus);
|
||||
document.removeEventListener('focusout', handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div hidden={hidden} style={{ left: '256px' }} ref={keyboardRef} className='fixed flex justify-center items-center gap-32 rounded-2xl pointer-events-none z-1000'>
|
||||
{elements.map((e, i) => <div ref={sideRefs[i]} key={i} data-shift={shift} className='flex justify-center items-center size-48 rounded-full border-8 ring-4 ring-offset-48 ring-offset-base-300 ring-base-100 data-[shift=true]:ring-base-content border-base-300 backdrop-blur-2xl bg-base-100/40'>
|
||||
<div ref={circleRefs[i]} className='absolute bg-base-300 rounded-full size-8'></div>
|
||||
{e.elements}
|
||||
<div className='text-3xl font-semibold' ref={keyIndicatorRefs[i]}></div>
|
||||
</div>)}
|
||||
<div className='absolute flex gap-2 mb-92'>{Object.entries(Shortcuts).map(([key, value], i) => <ShortcutPrompt key={i} id={key} icon={GamepadIconMap[value]} label={KeyElements[key] ?? key} />)}</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { FocusEventHandler, Ref, RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { oneShot } from "../scripts/audio/audio";
|
||||
import { Search } from "lucide-react";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useEventListener } from "usehooks-ts";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
import { showKeyboardHandler } from "../scripts/utils";
|
||||
import useActiveControl from "../scripts/gamepads";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
function SearchInput (data: {
|
||||
id: string;
|
||||
|
|
@ -16,6 +15,7 @@ function SearchInput (data: {
|
|||
compact: boolean | undefined;
|
||||
onInputFocus: () => void;
|
||||
setShowInput: (show: boolean) => void;
|
||||
className?: string;
|
||||
onSubmit: (search: string | undefined) => void;
|
||||
} & FocusParams)
|
||||
{
|
||||
|
|
@ -63,9 +63,7 @@ function SearchInput (data: {
|
|||
data.onSubmit?.(undefined);
|
||||
}, inputRef as any);
|
||||
|
||||
const handlInputFocus: FocusEventHandler<HTMLInputElement> = e => showKeyboardHandler(control as any, e.target);
|
||||
|
||||
return <label ref={ref} onFocus={data.onInputFocus} className='input rounded-full input-lg w-full max-w-xs has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content'>
|
||||
return <label ref={ref} onFocus={data.onInputFocus} className={twMerge('input rounded-full input-lg w-full max-w-xs bg-base-200 has-focus:bg-base-300 ring-primary focused:ring-7 has-focus:ring-7 has-focus:ring-base-content', data.className)}>
|
||||
<Search />
|
||||
<input
|
||||
onBlur={e =>
|
||||
|
|
@ -74,7 +72,6 @@ function SearchInput (data: {
|
|||
setLocalSearch(data.search);
|
||||
}}
|
||||
autoFocus={data.compact}
|
||||
onFocus={handlInputFocus}
|
||||
ref={inputRef}
|
||||
value={localSearch ?? ""}
|
||||
onChange={v => setLocalSearch(v.target.value)}
|
||||
|
|
@ -89,6 +86,7 @@ export default function HeaderSearchField (data: {
|
|||
autoSearch?: boolean;
|
||||
search: string | undefined,
|
||||
onSubmit: (search: string | undefined) => void;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
} & FocusParams)
|
||||
{
|
||||
|
|
@ -102,7 +100,7 @@ export default function HeaderSearchField (data: {
|
|||
|
||||
return <div ref={ref} className='flex items-center'>
|
||||
<FocusContext value={focusKey}>
|
||||
{(!data.compact || showInput) && <SearchInput autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
|
||||
{(!data.compact || showInput) && <SearchInput className={data.className} autoSearch={data.autoSearch} onFocus={data.onFocus} id={`${data.id}-field`} search={data.search} onSubmit={data.onSubmit} compact={data.compact} setShowInput={setShowInput} onInputFocus={focusSelf} />}
|
||||
{data.compact && !showInput && <RoundButton onAction={e => setShowInput(true)} className="header-icon sm:size-10 md:size-14" id={`${data.id}-field`} ><Search /></RoundButton>}
|
||||
</FocusContext>
|
||||
</div>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ContextList, DialogEntry, useContextDialog } from "./ContextDialog";
|
||||
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
|
||||
import { MatchRoute, useMatch, useMatchRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { useMatchRoute, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
import { getCurrentFocusKey } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { DoorOpen, Gamepad2, Puzzle, RefreshCcw, Settings, Store } from "lucide-react";
|
||||
import { systemApi } from "../scripts/clientApi";
|
||||
|
|
@ -10,6 +10,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
|||
{
|
||||
const navigate = useNavigate();
|
||||
const matchRoute = useMatchRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const options: DialogEntry[] = [
|
||||
{
|
||||
|
|
@ -95,7 +96,7 @@ export default function SelectMenu (data: { rootFocusKey: string; })
|
|||
}
|
||||
];
|
||||
const { dialog, setOpen, open } = useContextDialog('select-menu', {
|
||||
content: <ContextList showCloseButton={false} options={options} />,
|
||||
content: <><ContextList showCloseButton={false} options={options} /><div className="absolute left-2 right-2 top-2 text-base-content/20 text-center">{router.history.location.pathname}</div></>,
|
||||
className: 'absolute flex flex-col justify-center left-0 top-0 bottom-0 rounded-none max-h-screen',
|
||||
preferredChildFocusKey: FOCUS_KEYS.CONTEXT_DIALOG_OPTION('select-menu', options.find(o => o.selected)?.id ?? '')
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { MouseEventHandler } from "react";
|
||||
import { JSX, MouseEventHandler } from "react";
|
||||
import SvgIcon, { IconType } from "./SvgIcon";
|
||||
import classNames from "classnames";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
|
@ -6,8 +6,9 @@ import { twMerge } from "tailwind-merge";
|
|||
export default function ShortcutPrompt (data: {
|
||||
id: string;
|
||||
icon?: IconType;
|
||||
label?: string;
|
||||
label?: string | JSX.Element;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
onClick?: MouseEventHandler;
|
||||
})
|
||||
{
|
||||
|
|
@ -23,7 +24,7 @@ export default function ShortcutPrompt (data: {
|
|||
})
|
||||
)}
|
||||
>
|
||||
{data.icon && <SvgIcon className="size-6 portrait:size-6 md:size-8" icon={data.icon} />}
|
||||
{data.icon && <SvgIcon className={twMerge("size-6 portrait:size-6 md:size-8", data.iconClassName)} icon={data.icon} />}
|
||||
{data.label}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,36 +1,36 @@
|
|||
import { useContext } from 'react';
|
||||
import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
|
||||
import { GamePadButtonCode, Shortcut, useShortcutContext } from '../scripts/shortcuts';
|
||||
import { GamePadButtonCode, useShortcutContext } from '../scripts/shortcuts';
|
||||
import ShortcutPrompt from './ShortcutPrompt';
|
||||
import { IconType } from './SvgIcon';
|
||||
import { ShortcutsContext } from '../scripts/contexts';
|
||||
|
||||
export function FloatingShortcuts ()
|
||||
{
|
||||
return <div className="mobile:hidden fixed flex bottom-4 right-4 left-4 justify-between pointer-events-none z-1000"><Shortcuts /></div>;
|
||||
}
|
||||
|
||||
export const GamepadIconMap: 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: { centerElement?: any; })
|
||||
{
|
||||
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'
|
||||
};
|
||||
|
||||
|
||||
const keyboardMap: Record<GamePadButtonCode, string> = {
|
||||
[GamePadButtonCode.A]: 'ENTER',
|
||||
|
|
@ -62,7 +62,7 @@ export default function Shortcuts (data: { centerElement?: any; })
|
|||
key={s.button}
|
||||
id={`shortcut-${s.button}`}
|
||||
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
||||
icon={showKeyboard ? undefined : iconMap[s.button]}
|
||||
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
|
||||
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -72,7 +72,7 @@ export default function Shortcuts (data: { centerElement?: any; })
|
|||
key={s.button}
|
||||
id={`shortcut-${s.button}`}
|
||||
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
|
||||
icon={showKeyboard ? undefined : iconMap[s.button]}
|
||||
icon={showKeyboard ? undefined : GamepadIconMap[s.button]}
|
||||
label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import "virtual:svg-icons/register";
|
||||
import { StaticAssetPath } from "../gen/static-icon-assets.gen";
|
||||
import { CSSProperties } from "react";
|
||||
|
||||
type OnlySvgIcon<T extends string> = T extends `${infer Rest}.svg`
|
||||
? Rest
|
||||
|
|
@ -15,17 +16,19 @@ export default function SvgIcon ({
|
|||
icon,
|
||||
prefix = "icon",
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: {
|
||||
icon: IconType;
|
||||
prefix?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
})
|
||||
{
|
||||
const symbolId = `#${prefix}-${icon}`;
|
||||
|
||||
return (
|
||||
<svg className={className} {...props} aria-hidden="true">
|
||||
<svg style={style} className={className} {...props} aria-hidden="true">
|
||||
<use href={symbolId} />
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||
import { ContextList, DialogEntry, useContextDialog } from "../ContextDialog";
|
||||
import { getErrorMessage } from "react-error-boundary";
|
||||
import toast from "react-hot-toast";
|
||||
import { Hammer, RefreshCcw, Settings, Trash, Trophy } from "lucide-react";
|
||||
import { Hammer, RefreshCcw, RefreshCcwDot, Settings, Trash, Trophy } from "lucide-react";
|
||||
import MainActions from "./MainActions";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { useLocalStorage } from "usehooks-ts";
|
||||
import FocusTooltip from "../FocusTooltip";
|
||||
import { useBlocker, useRouter } from "@tanstack/react-router";
|
||||
import { useBlocker, useNavigate, useRouter } from "@tanstack/react-router";
|
||||
|
||||
function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractParams)
|
||||
{
|
||||
|
|
@ -32,6 +32,7 @@ function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; } & InteractP
|
|||
export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||
{
|
||||
const [, setDetailsSection] = useLocalStorage('details-section', 'screenshots');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fixMutation = useMutation({
|
||||
...fixSourceMutation,
|
||||
|
|
@ -64,7 +65,8 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
...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));
|
||||
router.history.back();
|
||||
},
|
||||
onError (error)
|
||||
{
|
||||
|
|
@ -84,9 +86,10 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
{
|
||||
contextOptions.push({
|
||||
id: 'delete',
|
||||
action: () =>
|
||||
action: (ctx) =>
|
||||
{
|
||||
deleteMutation.mutate();
|
||||
ctx.close();
|
||||
},
|
||||
icon: deleteMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Trash />,
|
||||
content: deleteMutation.isPending ? "Deleting" : "Delete",
|
||||
|
|
@ -98,12 +101,16 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
{
|
||||
contextOptions.push({
|
||||
id: "fix_source",
|
||||
async action (ctx)
|
||||
action (ctx)
|
||||
{
|
||||
if (!data.game) return;
|
||||
await fixMutation.mutateAsync({ source: data.game.id.source, id: data.game.id.id });
|
||||
fixMutation.mutate({ source: data.game.id.source, id: data.game.id.id }, {
|
||||
onSuccess (data, variables, onMutateResult, context)
|
||||
{
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
});
|
||||
ctx.close();
|
||||
router.navigate({ replace: true });
|
||||
},
|
||||
icon: fixMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <Hammer />,
|
||||
content: "Try Fix Source",
|
||||
|
|
@ -126,6 +133,18 @@ export default function ActionButtons (data: { game?: FrontEndGameTypeDetailed,
|
|||
content: "Update Metadata",
|
||||
type: "primary"
|
||||
});
|
||||
|
||||
contextOptions.push({
|
||||
id: 'update-custom',
|
||||
action (ctx)
|
||||
{
|
||||
ctx.close();
|
||||
navigate({ to: '/game/update/$source/$id', params: { source: data.source, id: data.id } });
|
||||
},
|
||||
icon: updateMutation.isPending ? <span className="loading loading-spinner loading-lg"></span> : <RefreshCcwDot />,
|
||||
content: "Update Metadata (Interactive)",
|
||||
type: "primary"
|
||||
});
|
||||
}
|
||||
|
||||
const { setOpen, dialog: settingsDialog } = useContextDialog("settings-context", { content: <ContextList disableCloseButton={deleteMutation.isPending} options={contextOptions} />, canClose: !deleteMutation.isPending });
|
||||
|
|
|
|||
80
src/mainview/components/game/GameLookup.tsx
Normal file
80
src/mainview/components/game/GameLookup.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { gameLookup } from "@/mainview/scripts/queries/romm";
|
||||
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import HeaderSearchField from "../HeaderSearchField";
|
||||
import { GamePadButtonCode, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { scrollIntoViewHandler } from "@/mainview/scripts/utils";
|
||||
import { FOCUS_KEYS } from "@/mainview/scripts/types";
|
||||
|
||||
function Result (data: {
|
||||
match: GameLookup;
|
||||
showPlatform: boolean;
|
||||
selected: boolean;
|
||||
} & InteractParams)
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
focusKey: FOCUS_KEYS.GAME_MATCH({ source: data.match.source, id: data.match.id }),
|
||||
onFocus (l, p, d) { scrollIntoViewHandler({ block: 'center' })(focusKey, ref.current, d); },
|
||||
onEnterPress (p, d) { data.onAction?.({ focusKey }); }
|
||||
});
|
||||
useShortcuts(focusKey, () => [{
|
||||
label: "Select", action (e)
|
||||
{
|
||||
data.onAction?.({ event: e, focusKey });
|
||||
}, button: GamePadButtonCode.A
|
||||
}]);
|
||||
return <li ref={ref} onClick={(e) => data.onAction?.({ event: e.nativeEvent, focusKey })} className='flex gap-4 items-center not-mobile:drop-shadow-md light:bg-base-100 dark:bg-base-300 p-2 rounded-2xl focusable focusable-primary focusable-hover cursor-pointer'>
|
||||
{data.match.coverUrl ? <div>
|
||||
<img className='h-32 rounded-xl' src={data.match.coverUrl}></img>
|
||||
{data.selected && <span className="absolute top-4 left-4 bg-accent drop-shadow-sm text-accent-content ring-2 ring-base-100 p-1 rounded-full"><Check className="size-5" /></span>}
|
||||
</div> : <div></div>}
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='font-bold text-xl'>{data.match.name}</div>
|
||||
<div className='text-base-content/60 max-w-lg max-h-12 overflow-hidden text-ellipsis text-wrap wrap-anywhere'>{data.match.summary}</div>
|
||||
<ul className='flex flex-wrap gap-1'>
|
||||
{data.showPlatform && <>
|
||||
{data.match.platforms.map(p => <li className="bg-primary text-primary-content p-1 px-2 text-sm rounded-2xl">{p.name}</li>)}
|
||||
<div className="divider divider-horizontal m-0"></div>
|
||||
</>}
|
||||
{data.match.genres.map(g => <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{g}</li>)}
|
||||
{data.match.first_release_date && <li className='bg-base-100 p-1 px-2 text-sm rounded-2xl'>{new Date(data.match.first_release_date).toDateString()}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</li>;
|
||||
}
|
||||
|
||||
function SearchField (data: { setSearch: (search: string | undefined) => void; search: string | undefined; })
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({ focusKey: `search-field-section` });
|
||||
return <div ref={ref} className='flex w-full justify-center my-4'>
|
||||
<FocusContext value={focusKey}>
|
||||
<HeaderSearchField className="md:min-w-xl" onSubmit={v => data.setSearch(v)} search={data.search} id='search-field' />
|
||||
</FocusContext>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export default function GameLookup (data: {
|
||||
search: string | undefined,
|
||||
setSearch: (search: string | undefined) => void,
|
||||
onSelect: (match: GameLookup) => void;
|
||||
showPlatforms?: boolean;
|
||||
selected?: FrontEndId;
|
||||
})
|
||||
{
|
||||
const { data: lookups, isFetching } = useQuery({ ...gameLookup(data.search), staleTime: 1000 * 60 * 60 });
|
||||
|
||||
return <div>
|
||||
<SearchField setSearch={data.setSearch} search={data.search} />
|
||||
<div className="divider">{isFetching ? <span className="loading loading-spinner loading-lg"></span> : <Search className='size-10' />}Results</div>
|
||||
<ul className='flex flex-col gap-2 justify-center p-2 px-4'>
|
||||
{lookups?.map((l, i) =>
|
||||
{
|
||||
return <Result key={i} selected={data.selected?.id === l.id && data.selected?.source === l.source} showPlatform={data.showPlatforms ?? false} match={l} onAction={(ctx) =>
|
||||
{
|
||||
data.onSelect(l);
|
||||
}} />;
|
||||
})}
|
||||
</ul>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { gameInvalidationQuery, installMutation, playMutation } from "@/mainview
|
|||
import ActionButton from "./ActionButton";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { DownloadSourceType } from "@/shared/constants";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
|
||||
export default function MainActions (data: { game?: FrontEndGameTypeDetailed, source: string, id: string; })
|
||||
{
|
||||
|
|
@ -118,10 +119,14 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
};
|
||||
|
||||
let mainButton: any | undefined = undefined;
|
||||
let showAllCommandsAction: ((focusKey: string) => void) | undefined;
|
||||
let mainAction: () => void;
|
||||
if (status === 'installed')
|
||||
{
|
||||
if (validCommands.length > 1) showAllCommandsAction = (focusKey) => showAllCommands(true, focusKey);
|
||||
mainAction = () => handlePlay(validDefaultCommand);
|
||||
mainButton = <div className="flex gap-2">
|
||||
<ActionButton onAction={() => handlePlay(validDefaultCommand)} tooltip={validDefaultCommand?.label ?? details}
|
||||
<ActionButton onAction={mainAction} tooltip={validDefaultCommand?.label ?? details}
|
||||
key="primary"
|
||||
type='primary'
|
||||
id="mainAction"
|
||||
|
|
@ -130,25 +135,26 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
|
||||
</ActionButton>
|
||||
|
||||
{validCommands.length > 1 &&
|
||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommands(true, 'allActionsBtn')}>
|
||||
{showAllCommandsAction &&
|
||||
<ActionButton className="size-11! header-icon-small" tooltip={"All Commands"} type="base" id="allActionsBtn" onAction={() => showAllCommandsAction!('allActionsBtn')}>
|
||||
<EllipsisVertical />
|
||||
</ActionButton>}</div>;
|
||||
}
|
||||
else if (error)
|
||||
{
|
||||
mainAction = () =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
router.navigate({ to: '/settings/directories' });
|
||||
}
|
||||
};
|
||||
mainButton = <ActionButton
|
||||
key="error"
|
||||
tooltip={error}
|
||||
tooltipType="error"
|
||||
type='error'
|
||||
onAction={() =>
|
||||
{
|
||||
if (status === 'missing-emulator')
|
||||
{
|
||||
router.navigate({ to: '/settings/directories' });
|
||||
}
|
||||
}}
|
||||
onAction={mainAction}
|
||||
id="mainAction">
|
||||
<TriangleAlert />
|
||||
</ActionButton>;
|
||||
|
|
@ -167,26 +173,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
{
|
||||
icon = <Import />;
|
||||
}
|
||||
mainAction = () =>
|
||||
{
|
||||
if (installMut.isPending) return;
|
||||
switch (status)
|
||||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
if (installSources && installSources.length > 1)
|
||||
{
|
||||
showInstallSource(true, 'mainAction');
|
||||
} else
|
||||
{
|
||||
installMut.mutate({});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
mainButton = <ActionButton
|
||||
key={status ?? 'unknown'}
|
||||
onAction={() =>
|
||||
{
|
||||
if (installMut.isPending) return;
|
||||
switch (status)
|
||||
{
|
||||
case 'present':
|
||||
case 'install':
|
||||
if (installSources && installSources.length > 1)
|
||||
{
|
||||
showInstallSource(true, 'mainAction');
|
||||
} else
|
||||
{
|
||||
installMut.mutate({});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}}
|
||||
onAction={mainAction}
|
||||
tooltip={details ?? status}
|
||||
type='primary'
|
||||
id="mainAction">
|
||||
|
|
@ -194,6 +201,27 @@ export default function MainActions (data: { game?: FrontEndGameTypeDetailed, so
|
|||
</ActionButton>;
|
||||
}
|
||||
|
||||
useShortcuts('mainAction', () =>
|
||||
{
|
||||
const shortcuts: Shortcut[] = [{
|
||||
button: GamePadButtonCode.A,
|
||||
action: mainAction
|
||||
}];
|
||||
|
||||
if (showAllCommandsAction)
|
||||
shortcuts.push(
|
||||
{
|
||||
button: GamePadButtonCode.Y,
|
||||
label: "All Commands",
|
||||
action (e)
|
||||
{
|
||||
showAllCommandsAction('mainAction');
|
||||
},
|
||||
});
|
||||
|
||||
return shortcuts;
|
||||
}, [showAllCommandsAction, mainAction]);
|
||||
|
||||
const { dialog: allCommandDialog, setOpen: showAllCommands } = useContextDialog('all-commands-dialog', {
|
||||
content: <ContextList options={validCommands.map((c, i) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import { PathSettingsOptionBase, PathSettingsOptionParams } from "./PathSettingsOption";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { changeDownloadsMutation } from "@queries/settings";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { changeDownloadsMutation, getSettingQuery } from "@queries/settings";
|
||||
import { SettingsType } from "@/shared/constants";
|
||||
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
||||
export default function DownloadDirectoryOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
|
||||
const setSettingMutation = useMutation({
|
||||
...changeDownloadsMutation,
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
|
|
@ -25,6 +27,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
|
|||
requireConfirmation={data.requireConfirmation}
|
||||
isDirectoryPicker={true}
|
||||
localValue={localValue}
|
||||
defaultValue={defaultValue as any}
|
||||
setLocalValue={(v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { HTMLInputTypeAttribute, JSX } from "react";
|
||||
import { JSX } from "react";
|
||||
import { LocalSettingsSchema, LocalSettingsType } from "@shared/constants";
|
||||
import { OptionSpace } from "./OptionSpace";
|
||||
import { OptionInput } from "./OptionInput";
|
||||
|
|
@ -6,14 +6,9 @@ import { useLocalStorage } from "usehooks-ts";
|
|||
import { OptionDropdown } from "./OptionDropdown";
|
||||
|
||||
export function LocalOption (data: {
|
||||
label: string;
|
||||
id: keyof LocalSettingsType;
|
||||
type: HTMLInputTypeAttribute | 'dropdown';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
values?: string[];
|
||||
icon?: JSX.Element;
|
||||
children?: any;
|
||||
})
|
||||
|
|
@ -22,9 +17,20 @@ export function LocalOption (data: {
|
|||
deserializer: (v) => LocalSettingsSchema.shape[data.id].parse(JSON.parse(v))
|
||||
});
|
||||
|
||||
const schema = LocalSettingsSchema.shape[data.id].toJSONSchema();
|
||||
const typeMapping: Record<string, string> = {
|
||||
string: 'text',
|
||||
integer: 'range',
|
||||
number: 'range',
|
||||
boolean: 'checkbox'
|
||||
};
|
||||
|
||||
return (
|
||||
<OptionSpace id={`${data.id}-space`} label={data.label}>
|
||||
{data.type === 'dropdown' && data.values && <OptionDropdown values={data.values} icon={data.icon}
|
||||
<OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col gap-1">
|
||||
<div>{schema.title ?? data.id}</div>
|
||||
<div className="text-base-content/40 text-sm">{schema.description}</div>
|
||||
</div>}>
|
||||
{!!schema.enum && <OptionDropdown values={schema.enum.map(v => String(v))} icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
placeholder={data.placeholder}
|
||||
defaultValue={localValue}
|
||||
|
|
@ -33,12 +39,12 @@ export function LocalOption (data: {
|
|||
setLocalValue(v);
|
||||
}}
|
||||
value={localValue} />}
|
||||
{data.type !== 'dropdown' && <OptionInput
|
||||
{!schema.enum && <OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
type={data.type}
|
||||
min={data.min}
|
||||
max={data.max}
|
||||
type={schema.type ? typeMapping[schema.type] : 'text'}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={data.step}
|
||||
placeholder={data.placeholder}
|
||||
defaultValue={localValue}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import { FocusEventHandler, HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribu
|
|||
import { twMerge } from "tailwind-merge";
|
||||
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";
|
||||
import { GamePadButtonCode, Shortcut, useShortcuts } from "@/mainview/scripts/shortcuts";
|
||||
import { showKeyboardHandler } from "@/mainview/scripts/utils";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
|
||||
export function OptionInput (data: {
|
||||
|
|
@ -106,7 +104,6 @@ export function OptionInput (data: {
|
|||
{
|
||||
option.focus();
|
||||
setInputFocused(true);
|
||||
showKeyboardHandler(control as any, e.target);
|
||||
};
|
||||
|
||||
const handleInputBlur = (e: any) =>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export function useOptionContext (params?: { onOptionEnterPress?: () => void; })
|
|||
export function OptionSpace (data: {
|
||||
id?: string;
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
focusable?: boolean;
|
||||
children?: any | any[];
|
||||
label?: string | JSX.Element | ((focused: boolean) => JSX.Element);
|
||||
|
|
@ -90,7 +91,7 @@ export function OptionSpace (data: {
|
|||
{!!labelElement && <div className="flex gap-2 items-center flex-1 md:text-lg pr-4">
|
||||
{labelElement}
|
||||
</div>}
|
||||
<div className="flex flex-1 justify-end-safe">
|
||||
<div className={twMerge("flex flex-1 justify-end-safe", data.innerClassName)}>
|
||||
{data.children}
|
||||
</div>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
|||
export interface PathSettingsOptionParams
|
||||
{
|
||||
label: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string>;
|
||||
id: string;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
icon?: JSX.Element;
|
||||
|
|
@ -24,10 +24,11 @@ export interface PathSettingsOptionParams
|
|||
allowNewFolderCreation?: boolean;
|
||||
}
|
||||
|
||||
export function PathSettingsOption (data: PathSettingsOptionParams)
|
||||
export function PathSettingsOption (data: PathSettingsOptionParams & { id: KeysWithValueAssignableTo<SettingsType, string>; })
|
||||
{
|
||||
const [localValue, setLocalValue] = useState<string | undefined>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
|
||||
const setMutation = useMutation({
|
||||
...setSettingMutation(data.id),
|
||||
onSuccess: (d, v, r, cx) =>
|
||||
|
|
@ -44,6 +45,7 @@ export function PathSettingsOption (data: PathSettingsOptionParams)
|
|||
save={setMutation.mutate}
|
||||
localValue={localValue}
|
||||
allowNewFolderCreation={data.allowNewFolderCreation}
|
||||
defaultValue={defaultValue as any}
|
||||
setLocalValue={(v) =>
|
||||
{
|
||||
setLocalValue(v);
|
||||
|
|
@ -56,16 +58,17 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
localValue: string | undefined;
|
||||
setLocalValue: (value: string | undefined) => void;
|
||||
isDirty: boolean;
|
||||
className?: string;
|
||||
defaultValue: string | undefined;
|
||||
})
|
||||
{
|
||||
const [isBrowsing, setIsBrowsing] = useState(false);
|
||||
const { data: defaultValue } = useQuery(getSettingQuery(data.id));
|
||||
const changed = defaultValue !== data.localValue;
|
||||
const changed = data.defaultValue !== data.localValue;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
data.setLocalValue(String(defaultValue));
|
||||
}, [defaultValue]);
|
||||
data.setLocalValue(String(data.defaultValue ?? ''));
|
||||
}, [data.defaultValue]);
|
||||
|
||||
const handleSelectPath = (path: string) =>
|
||||
{
|
||||
|
|
@ -92,7 +95,8 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
};
|
||||
|
||||
return (
|
||||
<OptionSpace id={`${data.id}-space`} className="gap-2" label={<>{data.label}{changed && <Pen />}</>}>
|
||||
<OptionSpace id={`${data.id}-space`} innerClassName="gap-2" className={data.className} label={<>{data.label}{changed && <Pen />}</>}>
|
||||
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={`${data.id}-input`}
|
||||
|
|
@ -105,7 +109,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
}}
|
||||
value={data.localValue}
|
||||
/>
|
||||
<Button id={`${data.id}-browse`} className="ring-accent-content" focusClassName="ring-7" onAction={() =>
|
||||
<Button id={`${data.id}-browse`} className="focusable focusable-accent" onAction={() =>
|
||||
{
|
||||
setIsBrowsing(true);
|
||||
data.onBrowseAction?.(data.localValue);
|
||||
|
|
@ -113,7 +117,7 @@ export function PathSettingsOptionBase (data: PathSettingsOptionParams & {
|
|||
{data.isDirectoryPicker ? <FolderSearch /> : <FileSearchCorner />}
|
||||
</Button>
|
||||
{data.requireConfirmation === true && <Button
|
||||
disabled={defaultValue === data.localValue}
|
||||
disabled={data.defaultValue === data.localValue}
|
||||
id={`${data.id}-save`}
|
||||
onAction={() => data.save(data.localValue)}
|
||||
type="button">
|
||||
|
|
|
|||
|
|
@ -15,10 +15,15 @@ export const { useAppForm: useSettingsForm, useTypedAppFormContext: useSettingsF
|
|||
formComponents: {}
|
||||
});
|
||||
|
||||
function FormOption (data: { type: HTMLInputTypeAttribute, icon?: JSX.Element; label?: string | JSX.Element; placeholder?: string; })
|
||||
function FormOption (data: {
|
||||
type: HTMLInputTypeAttribute,
|
||||
icon?: JSX.Element;
|
||||
label?: string | JSX.Element;
|
||||
placeholder?: string;
|
||||
})
|
||||
{
|
||||
const field = useFieldContext<string>();
|
||||
return <OptionSpace id={`${field.name}-space`} label={<div className="flex flex-1 gap-2">
|
||||
return <OptionSpace id={`${field.name}-space`} label={<div className="flex items-center flex-1 gap-2">
|
||||
{data.label}
|
||||
{field.getMeta().errors.length > 0 && <div className="badge badge-error">
|
||||
{field.state.meta.errors.map(e => e.message).join(',')}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getSettingQuery, setSettingMutation } from "@queries/settings";
|
|||
|
||||
export function SettingsOption (data: {
|
||||
label: string;
|
||||
help?: string;
|
||||
id: KeysWithValueAssignableTo<SettingsType, string | boolean>;
|
||||
type: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
|
|
@ -35,7 +36,10 @@ export function SettingsOption (data: {
|
|||
}, [dirty, setDirty, localValue]);
|
||||
|
||||
return (
|
||||
<OptionSpace id={`${data.id}-space`} label={data.label}>
|
||||
<OptionSpace id={`${data.id}-space`} label={<div className="flex flex-col">
|
||||
<div>{data.label}</div>
|
||||
<div className="text-base-content/40 text-sm">{data.help}</div>
|
||||
</div>}>
|
||||
<OptionInput
|
||||
icon={data.icon}
|
||||
name={data.id ?? ""}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import
|
|||
useFocusable,
|
||||
FocusContext,
|
||||
} from "@noriginmedia/norigin-spatial-navigation";
|
||||
import { Button } from "../options/Button";
|
||||
import useActiveControl from "@/mainview/scripts/gamepads";
|
||||
import { ChevronRight, CircleQuestionMark, SearchAlert } from "lucide-react";
|
||||
import { 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";
|
||||
|
|
@ -15,14 +13,14 @@ import { oneShot } from "@/mainview/scripts/audio/audio";
|
|||
interface MissingCardProps
|
||||
{
|
||||
emulator: FrontEndEmulator;
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
|
||||
}
|
||||
|
||||
function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
|
||||
{
|
||||
const handleSelect = () =>
|
||||
{
|
||||
onSelect?.(em.name, focusKey);
|
||||
onSelect?.(em, focusKey);
|
||||
oneShot('click');
|
||||
};
|
||||
|
||||
|
|
@ -31,7 +29,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
|
|||
onEnterPress: handleSelect,
|
||||
});
|
||||
useShortcuts(focusKey, () => [{ button: GamePadButtonCode.A, label: "Details", action: handleSelect }], [handleSelect]);
|
||||
const { isMouse } = useActiveControl();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -40,7 +37,7 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
|
|||
tabIndex={0}
|
||||
onClick={handleSelect}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSelect}
|
||||
className={"focusable focusable-accent bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
|
||||
className={"focusable focusable-accent focusable-hover cursor-pointer bg-base-100 rounded-4xl transition-all focused:animate-scale-small shadow-lg"}
|
||||
>
|
||||
<div className="card-body p-5 gap-3">
|
||||
<div className="flex gap-4">
|
||||
|
|
@ -57,10 +54,6 @@ function MissingCard ({ emulator: em, onSelect }: MissingCardProps)
|
|||
<p className="text-base-content/40 mt-0.5">{em.systems?.map(s => s.name).join(',')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center grow h-8">
|
||||
<p className="text-xs text-error/80 leading-relaxed">{em.name}</p>
|
||||
{isMouse && <Button className="hover:btn-error hover:text-primary-content text-base-content/40 font-normal md:text-base" onAction={handleSelect} id={`details-${em.name}`}>Details<ChevronRight /></Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -71,7 +64,7 @@ export function MissingEmulatorsSection ({
|
|||
onSelect,
|
||||
}: {
|
||||
emulators: FrontEndEmulator[];
|
||||
onSelect?: (id: string, focusKey: string) => void;
|
||||
onSelect?: (em: FrontEndEmulator, focusKey: string) => void;
|
||||
})
|
||||
{
|
||||
const { ref, focusKey } = useFocusable({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue