fix: Fixed issues on windows

feat: Implemented mouse and gamepad automatic switching
fix: Made touch screen work better on the steam deck
This commit is contained in:
Simeon Radivoev 2026-02-24 18:58:48 +02:00
parent e4df8fb9fb
commit b4a89385d0
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
24 changed files with 334 additions and 137 deletions

View file

@ -32,7 +32,7 @@ export async function getDevices (): Promise<Drive[]>
{ {
const blockDevicesRaw = await si.blockDevices(); const blockDevicesRaw = await si.blockDevices();
const layout = await si.diskLayout(); const layout = await si.diskLayout();
const blockDevices = blockDevicesRaw.filter(l => l.device && l.type === 'part' && l.mount); const blockDevices = blockDevicesRaw.filter(l => l.device && (l.type === 'part' || l.type === 'disk') && l.mount);
const fsSizes = await si.fsSize(); const fsSizes = await si.fsSize();
const sizes = new Map(fsSizes.map(s => [s.mount, s])); const sizes = new Map(fsSizes.map(s => [s.mount, s]));
const layoutMap = new Map(layout.map(l => [l.device, l])); const layoutMap = new Map(layout.map(l => [l.device, l]));
@ -65,6 +65,8 @@ export async function getDevicesCurated (): Promise<Drive[]>
const devices = await getDevices(); const devices = await getDevices();
drives.push(...devices.filter(d => d.hasWriteAccess)); drives.push(...devices.filter(d => d.hasWriteAccess));
if (process.platform !== 'win32')
{
const homeDir = os.homedir(); const homeDir = os.homedir();
const homeDirDevice = devices.filter(d => d.mountPoint).reverse() const homeDirDevice = devices.filter(d => d.mountPoint).reverse()
.find(d => homeDir.startsWith(d.mountPoint!)); .find(d => homeDir.startsWith(d.mountPoint!));
@ -86,6 +88,7 @@ export async function getDevicesCurated (): Promise<Drive[]>
hasWriteAccess hasWriteAccess
}); });
} }
}
return drives; return drives;
} }

View file

@ -12,7 +12,6 @@ import { getDevices, getDevicesCurated } from "./drives";
import getFolderSize from "get-folder-size"; import getFolderSize from "get-folder-size";
import si from 'systeminformation'; import si from 'systeminformation';
// steam://open/keyboard?XPosition=%i&YPosition=%i&Width=%i&Height=%i&Mode=%d
export const system = new Elysia({ prefix: '/api/system' }) export const system = new Elysia({ prefix: '/api/system' })
.post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) => .post('/show_keyboard', async ({ body: { XPosition, YPosition, Width, Height } }) =>
{ {
@ -67,12 +66,23 @@ export const system = new Elysia({ prefix: '/api/system' })
.get('/drives', async () => .get('/drives', async () =>
{ {
const drives = await getDevices(); const drives = await getDevices();
if (process.platform === 'win32')
return drives.map(d =>
{
d.mountPoint += '/';
return d;
});
return drives; return drives;
}) })
// Drives that are vaiable for downloads
.get('/drives/download', async () => .get('/drives/download', async () =>
{ {
const drives = await getDevicesCurated(); const drives = await getDevicesCurated();
const downloadsPath = config.get('downloadPath'); let downloadsPath = config.get('downloadPath');
if (!path.isAbsolute(downloadsPath))
{
downloadsPath = path.resolve(process.cwd(), downloadsPath);
}
const currentDownloadsSize = await getFolderSize(downloadsPath); const currentDownloadsSize = await getFolderSize(downloadsPath);
let used = false; let used = false;
const drivesDownload: DownloadsDrive[] = drives const drivesDownload: DownloadsDrive[] = drives
@ -115,6 +125,7 @@ export const system = new Elysia({ prefix: '/api/system' })
drives: drivesDownload, drives: drivesDownload,
}; };
}) })
// Create Folder
.put('/dirs', async ({ body: { dirname, name } }) => .put('/dirs', async ({ body: { dirname, name } }) =>
{ {
await fs.mkdir(path.join(dirname, name)); await fs.mkdir(path.join(dirname, name));
@ -123,7 +134,11 @@ export const system = new Elysia({ prefix: '/api/system' })
}) })
.get('/dirs', async ({ query: { path: startingPath } }) => .get('/dirs', async ({ query: { path: startingPath } }) =>
{ {
const currentPath = startingPath ?? dirname(Bun.main); let currentPath = startingPath ?? dirname(process.cwd());
if (!path.isAbsolute(currentPath))
{
currentPath = path.resolve(process.cwd(), currentPath);
}
const paths = await fs.readdir(currentPath, { withFileTypes: true }); const paths = await fs.readdir(currentPath, { withFileTypes: true });
return { return {
name: path.basename(currentPath), name: path.basename(currentPath),

View file

@ -48,7 +48,7 @@ export function AnimatedBackground (data: {
let backgroundElements: JSX.Element | undefined = undefined; let backgroundElements: JSX.Element | undefined = undefined;
if (true) if (true)
{ {
backgroundElements = <div id="container"> backgroundElements = <div id="container" className='md:visible sm:invisible'>
<div id="container-inside"> <div id="container-inside">
<div className={bgColor} id="circle-small"></div> <div className={bgColor} id="circle-small"></div>
<div className={bgColor} id="circle-medium"></div> <div className={bgColor} id="circle-medium"></div>
@ -66,7 +66,7 @@ export function AnimatedBackground (data: {
> >
{!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>} {!!lastBackgroundUrl && <div className='absolute w-full h-full' style={{ background: backgroundStyle(lastBackgroundUrl), zIndex: -4 }}></div>}
{!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>} {!!backgroundUrl && <div key={backgroundUrl} className='absolute w-full h-full animate__animated animate__fadeIn' style={{ background: backgroundStyle(backgroundUrl), zIndex: -3 }}></div>}
{blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl"} style={{ zIndex: -2 }}></div>} {blurBackground && <div className={"absolute w-full h-full backdrop-blur-3xl md:visible sm:invisible"} style={{ zIndex: -2 }}></div>}
{data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}> {data.animated && animateBackground && <div className="absolute overflow-hidden w-full h-full" style={{ zIndex: -1 }}>
{backgroundElements} {backgroundElements}
</div>} </div>}

View file

@ -1,10 +1,11 @@
import import
{ {
FocusContext, FocusContext,
FocusDetails,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import { GameMeta } from "../../shared/constants"; import { GameMeta } from "../../shared/constants";
import GameCard, { GameCardParams } from "./GameCard"; import GameCard, { GameCardFocusHandler, GameCardParams } from "./GameCard";
import { JSX } from "react"; import { JSX } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts";
@ -22,7 +23,7 @@ export function CardList (data: {
games: GameMetaExtra[]; games: GameMetaExtra[];
grid?: boolean; grid?: boolean;
onSelectGame?: (id: string) => void; onSelectGame?: (id: string) => void;
onGameFocus?: (id: string, node: HTMLElement) => void; onGameFocus?: GameCardFocusHandler;
className?: string; className?: string;
}) })
{ {
@ -54,10 +55,10 @@ export function CardList (data: {
data-index={i} data-index={i}
title={g.title} title={g.title}
subtitle={g.subtitle ?? ""} subtitle={g.subtitle ?? ""}
onFocus={(id, node) => onFocus={(id, node, details) =>
{ {
g.onFocus?.(); g.onFocus?.(details);
data.onGameFocus?.(id, node); data.onGameFocus?.(id, node, details);
}} }}
onAction={handleAction} onAction={handleAction}
preview={preview} preview={preview}
@ -74,7 +75,7 @@ export function CardList (data: {
ref={ref} ref={ref}
save-child-focus="session" save-child-focus="session"
className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ", className={twMerge("my-6 items-center justify-center-safe h-(--game-card-height) ",
data.grid ? "card-grid h-fit gap-5" : 'card-list gap-6', data.grid ? "card-grid h-fit gap-5" : 'card-list md:gap-6 sm:gap-2',
data.className data.className
)} )}
onKeyDown={(e) => onKeyDown={(e) =>

View file

@ -4,12 +4,13 @@ import { useSuspenseQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { CardList, GameMetaExtra } from "./CardList"; import { CardList, GameMetaExtra } from "./CardList";
import { SaveSource } from "../scripts/spatialNavigation"; import { SaveSource } from "../scripts/spatialNavigation";
import { GameCardFocusHandler } from "./GameCard";
export default function CollectionList (data: { export default function CollectionList (data: {
id: string, id: string,
setBackground: (url: string) => void; setBackground: (url: string) => void;
className?: string; className?: string;
onFocus?: (node: HTMLElement) => void; onFocus?: GameCardFocusHandler;
}) })
{ {
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,12 +43,12 @@ export default function CollectionList (data: {
SaveSource('game-list'); SaveSource('game-list');
navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } }); navigate({ to: `/collection/${id}`, viewTransition: { types: ['zoom-in'] } });
}} }}
onGameFocus={(id, node) => onGameFocus={(id, node, details) =>
{ {
data.setBackground( data.setBackground(
`https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`, `https://picsum.photos/id/${10 + (id ?? 0)}/1920/1080.webp`,
); );
data.onFocus?.(node); data.onFocus?.(id, node, details);
}} }}
/> />
); );

View file

@ -10,6 +10,7 @@ import { GamePadButtonCode, useShortcutContext, useShortcuts } from '../scripts/
import { Router } from '..'; import { Router } from '..';
import { PopSource } from '../scripts/spatialNavigation'; import { PopSource } from '../scripts/spatialNavigation';
import { GameListFilterType } from '@/shared/constants'; import { GameListFilterType } from '@/shared/constants';
import { GameCardFocusHandler } from './GameCard';
export interface CollectionsDetailParams export interface CollectionsDetailParams
{ {
@ -42,6 +43,14 @@ export function CollectionsDetail (data: CollectionsDetailParams)
useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]);
const { shortcuts } = useShortcutContext(); const { shortcuts } = useShortcutContext();
const handleScroll: GameCardFocusHandler = (id, node, details) =>
{
if (!(details.nativeEvent instanceof MouseEvent))
{
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
};
return ( return (
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'> <AnimatedBackground animated ref={ref} backgroundKey="home-background" className='flex'>
@ -56,7 +65,7 @@ export function CollectionsDetail (data: CollectionsDetailParams)
grid grid
setBackground={data.setBackground} setBackground={data.setBackground}
filters={data.filters} filters={data.filters}
onFocus={(node) => node.scrollIntoView({ block: 'center', behavior: 'smooth' })} onFocus={handleScroll}
id={`${focusKey}-list`}> id={`${focusKey}-list`}>
</GameList> </GameList>

View file

@ -30,7 +30,7 @@ export function OptionElement (data: DialogEntry & { onFocus?: () => void; class
const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined; const handleAction = data.action ? () => data.action?.({ close: context.close, focus: focusSelf }) : undefined;
const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({ const { ref, focused, focusSelf, focusKey, hasFocusedChild } = useFocusable({
focusKey: `${context.id}-list-option-${data.id}`, focusKey: `${context.id}-list-option-${data.id}`,
onEnterPress: handleAction, onEnterPress: data.shortcuts ? handleAction : undefined,
onFocus: handleFocus, onFocus: handleFocus,
trackChildren: typeof data.content !== 'string' trackChildren: typeof data.content !== 'string'
}); });

View file

@ -36,7 +36,7 @@ function List (data: {
const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext); const { setCurrentPath, startingPath, allowNewFolderCreation, currentPath, isDirectoryPicker } = useContext(FilePickerContext);
const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` }); const { ref, focusKey } = useFocusable({ focusKey: data.id, preferredChildFocusKey: `${data.id}...` });
const handleReturn = () => setCurrentPath(data.parentPath); const handleReturn = () => setCurrentPath(data.parentPath);
useShortcuts(focusKey, () => [{ label: "Directoy Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]); useShortcuts(focusKey, () => [{ label: "Directory Up", button: GamePadButtonCode.L1, action: handleReturn }], [handleReturn]);
return <div ref={ref}> return <div ref={ref}>
<FocusContext value={focusKey}> <FocusContext value={focusKey}>
<ContextList showCloseButton={false} <ContextList showCloseButton={false}
@ -62,21 +62,25 @@ function List (data: {
icon = <></>; icon = <></>;
} }
const shortcuts: Shortcut[] = []; const shortcuts: Shortcut[] = [];
let action: () => void;
if (f.isDirectory) if (f.isDirectory)
{ {
shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) }); shortcuts.push({ label: "Enter", button: GamePadButtonCode.A, action: () => setCurrentPath(fullPath) });
action = () => setCurrentPath(fullPath);
if (isDirectoryPicker) if (isDirectoryPicker)
shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) }); shortcuts.push({ label: "Select", button: GamePadButtonCode.X, action: () => data.select(fullPath) });
} else } else
{ {
shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) }); shortcuts.push({ label: "Select", button: GamePadButtonCode.A, action: () => data.select(fullPath) });
action = () => data.select(fullPath);
} }
const entry: DialogEntry = { const entry: DialogEntry = {
content: f.name, content: f.name,
id: `${data.id}-${f.name}`, id: `${data.id}-${f.name}`,
type: 'primary', type: 'primary',
icon, icon,
shortcuts shortcuts,
action
}; };
return entry; return entry;
}), ...(allowNewFolderCreation && currentPath ? [{ }), ...(allowNewFolderCreation && currentPath ? [{
@ -157,7 +161,7 @@ function DriveElement (data: { id: string, isActive: boolean, label: string; onS
{ {
const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect }); const { ref, focused } = useFocusable({ focusKey: data.id, onEnterPress: data.onSelect });
return <li ref={ref} onClick={data.onSelect} className={twMerge( return <li ref={ref} onClick={data.onSelect} className={twMerge(
"flex bg-base-200 text-base-content rounded-full gap-2 items-center p-2 overflow-hidden max-w-xs", "flex bg-base-200 text-base-content rounded-full gap-2 items-center p-2 px-4 overflow-hidden max-w-xs cursor-pointer text-nowrap hover:bg-primary/40",
classNames({ classNames({
"bg-primary text-primary-content": data.isActive, "bg-primary text-primary-content": data.isActive,
"ring-7 ring-base-content": focused "ring-7 ring-base-content": focused
@ -238,8 +242,8 @@ export default function FilePicker (data: {
{ {
const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath); const [currentPath, setCurrentPath] = useState<string | undefined>(data.startingPath);
const { data: files, refetch: refetchFiles } = useQuery(filesQuery(currentPath, data.id)); const { data: files, refetch: refetchFiles, isLoading: filesLoading } = useQuery(filesQuery(currentPath, data.id));
const { data: drives } = useQuery(drivesQuery); const { data: drives, isLoading: drivesLoading } = useQuery(drivesQuery);
const fullPath = files ? path.join(files.parentPath, files.name) : ''; const fullPath = files ? path.join(files.parentPath, files.name) : '';
const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0]; const activeDrive = drives?.filter(d => !!d.mountPoint).sort((a, b) => b.mountPoint!.length - a.mountPoint!.length).filter(d => fullPath.startsWith(d.mountPoint!))[0];
@ -268,6 +272,7 @@ export default function FilePicker (data: {
}>{p}</a> }>{p}</a>
</li>)} </li>)}
</ul> </ul>
{(filesLoading || drivesLoading) && <span className="loading loading-spinner loading-lg"></span>}
</div>} </div>}
<ListWithDrives <ListWithDrives

View file

@ -7,6 +7,7 @@ import SvgIcon from "./SvgIcon";
import classNames from "classnames"; import classNames from "classnames";
import { useSearch } from "@tanstack/react-router"; import { useSearch } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import useActiveControl from "../scripts/gamepads";
function FilterCat ( function FilterCat (
data: { data: {
@ -33,16 +34,19 @@ function FilterCat (
} }
}, [filter]); }, [filter]);
const { isMouse } = useActiveControl();
return ( return (
<li <li
ref={ref} ref={ref}
onClick={focusSelf} onClick={focusSelf}
className={classNames( className={classNames(
"flex px-4 h-12 items-center justify-center rounded-full transition-all", "flex md:px-4 items-center justify-center rounded-full transition-all md:text-lg",
"sm:text-xs sm:px-2",
{ {
"bg-base-content px-3 text-base-300 drop-shadow cursor-default": "bg-base-content px-3 text-base-300 drop-shadow cursor-default":
focused || data.active, focused || data.active,
"ring-primary ring-7": focused, "ring-primary ring-7": focused && !isMouse,
"hover:bg-base-content/40 cursor-pointer": !focused, "hover:bg-base-content/40 cursor-pointer": !focused,
}, },
)} )}
@ -70,13 +74,13 @@ export function FilterUI (data: {
return ( return (
<div <div
ref={ref} ref={ref}
className="flex items-center justify-center gap-2" className="flex items-center sm:justify-start md:justify-center sm:ml-[15%] md:ml-0 gap-2"
save-child-focus="session" save-child-focus="session"
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm"> <ul className="flex flex-row bg-base-100 rounded-full p-1 drop-shadow-sm md:h-14 sm:h-8">
<li className=" flex px-4 h-12 items-center justify-center rounded-full"> <li className=" flex px-4 items-center justify-center rounded-full">
<SvgIcon className="size-8" icon="steamdeck_button_l1_outline" /> <SvgIcon className="sm:size-4 md:size-8" icon="steamdeck_button_l1_outline" />
</li> </li>
{Object.entries(data.options)?.map(([id, option]) => ( {Object.entries(data.options)?.map(([id, option]) => (
<FilterCat <FilterCat
@ -88,8 +92,8 @@ export function FilterUI (data: {
{...option} {...option}
/> />
))} ))}
<li className=" flex px-4 h-12 items-center justify-center rounded-full"> <li className="flex px-4 items-center justify-center rounded-full">
<SvgIcon className="size-8" icon="steamdeck_button_r1_outline" /> <SvgIcon className="sm:size-4 md:size-8" icon="steamdeck_button_r1_outline" />
</li> </li>
</ul> </ul>
</FocusContext.Provider> </FocusContext.Provider>

View file

@ -1,7 +1,8 @@
import { useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import { FocusDetails, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames"; import classNames from "classnames";
import { JSX, useEffect } from "react"; import { JSX } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import useActiveControl from "../scripts/gamepads";
export function GameCardSkeleton () export function GameCardSkeleton ()
{ {
@ -16,6 +17,8 @@ export function GameCardSkeleton ()
); );
} }
export type GameCardFocusHandler = (id: string, node: HTMLElement, details: FocusDetails) => void;
export interface GameCardParams export interface GameCardParams
{ {
title: string; title: string;
@ -27,7 +30,7 @@ export interface GameCardParams
id: string; id: string;
badges?: JSX.Element[]; badges?: JSX.Element[];
className?: string; className?: string;
onFocus?: (id: string, node: HTMLElement) => void; onFocus?: GameCardFocusHandler;
onBlur?: (id: string) => void; onBlur?: (id: string) => void;
onAction?: () => void; onAction?: () => void;
clickFocuses?: boolean; clickFocuses?: boolean;
@ -37,10 +40,11 @@ export default function GameCard (data: GameCardParams)
{ {
const { ref, focused, focusSelf } = useFocusable({ const { ref, focused, focusSelf } = useFocusable({
focusKey: data.focusKey, focusKey: data.focusKey,
onFocus: () => data.onFocus?.(data.id, ref.current as any), onFocus: (l, p, detals) => data.onFocus?.(data.id, ref.current as any, detals),
onEnterPress: () => data.onAction?.(), onEnterPress: () => data.onAction?.(),
onBlur: () => data.onBlur?.(data.id) onBlur: () => data.onBlur?.(data.id)
}); });
const { isPointer } = useActiveControl();
return ( return (
<li <li
@ -60,21 +64,24 @@ export default function GameCard (data: GameCardParams)
data.onAction?.(); data.onAction?.();
}} }}
className={twMerge( className={twMerge(
`game-card game-card-height flex flex-col justify-end z-5`, `game-card bg-base-300 game-card-height flex flex-col justify-end z-5 ring-primary`,
'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)', 'max-h-(--game-card-height) min-w-(--game-card-width) w-(--game-card-width)',
"overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer", "overflow-hidden transition-all duration-200 drop-shadow-lg cursor-pointer",
focused ?
`focused animate-wiggle ring-7 bg-base-content text-base-300 ring-primary drop-shadow-xl drop-shadow-black/30 scale-102 z-10` :
"bg-base-300 hover:bg-base-100 hover:scale-102 text-base-content",
classNames({ classNames({
"focused animate-wiggle ring-7 bg-base-content text-base-300 drop-shadow-xl drop-shadow-black/30 scale-102 z-10": focused && !isPointer,
"group hover:focused hover:animate-wiggle hover:ring-7 hover:bg-base-content hover:text-base-300 hover:drop-shadow-xl hover:drop-shadow-black/30 hover:scale-102 hover:z-10": isPointer,
"h-(--game-card-height)": typeof data.preview === "string" "h-(--game-card-height)": typeof data.preview === "string"
}), }),
data.className data.className
)} )}
> >
<div className={twMerge("overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all", focused ? "mt-2 mx-2" : "mt-2 mx-2")}> <div className={twMerge(
"overflow-hidden bg-base-400 h-full rounded-t-xl rounded-b-md transition-all",
focused ? "md:mt-2 md:mx-2" : "md:mt-2 md:mx-2",
focused ? "sm:mt-1 sm:mx-1" : "sm:mt-1 sm:mx-1",
)}>
{typeof data.preview === "string" ? ( {typeof data.preview === "string" ? (
<img className={classNames({ "animate-rotate-small": focused })} src={data.preview} ></img> <img className={classNames({ "animate-rotate-small": focused && !isPointer })} src={data.preview} ></img>
) : ( ) : (
typeof data.preview === 'function' ? data.preview({ focused }) : data.preview typeof data.preview === 'function' ? data.preview({ focused }) : data.preview
)}</div> )}</div>
@ -83,18 +90,21 @@ export default function GameCard (data: GameCardParams)
{data.badges?.map((b, i) => {data.badges?.map((b, i) =>
<div key={i} <div key={i}
className={ className={
twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 last:mr-4 transition-colors", twMerge("bg-base-100 text-base-content drop-shadow-lg overflow-hidden rounded-full p-1 md:last:mr-4 transition-colors",
classNames({ "bg-primary text-primary-content": focused }))} classNames({
"bg-primary text-primary-content": focused && !isPointer,
"group-hover:bg-primary group-hover:text-primary-content": isPointer
}))}
> >
{b} {b}
</div>) </div>)
} }
</div> </div>
<div className="flex flex-col p-4"> <div className="flex flex-col md:p-4 sm:p-2">
<div className="text-xl font-bold text-nowrap text-ellipsis overflow-hidden"> <div className="md:text-xl sm:text-sm font-bold text-nowrap text-ellipsis overflow-hidden">
{data.title} {data.title}
</div> </div>
<div className="text-s">{data.subtitle}</div> <div className="sm:text-xs md:text-sm text-nowrap">{data.subtitle}</div>
</div> </div>
</li > </li >
); );

View file

@ -6,6 +6,7 @@ import { SaveSource } from "../scripts/spatialNavigation";
import { rommApi } from "../scripts/clientApi"; import { rommApi } from "../scripts/clientApi";
import { HardDrive } from "lucide-react"; import { HardDrive } from "lucide-react";
import { JSX } from "react"; import { JSX } from "react";
import { GameCardFocusHandler } from "./GameCard";
export interface GameListParams export interface GameListParams
{ {
@ -14,7 +15,7 @@ export interface GameListParams
grid?: boolean, grid?: boolean,
setBackground?: (url: string) => void; setBackground?: (url: string) => void;
onGameSelect?: (id: FrontEndId) => void; onGameSelect?: (id: FrontEndId) => void;
onFocus?: (node: HTMLElement) => void; onFocus?: GameCardFocusHandler;
className?: string; className?: string;
} }
@ -52,7 +53,7 @@ export function GameList (data: GameListParams)
type="game" type="game"
grid={data.grid} grid={data.grid}
className={data.className} className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)} onGameFocus={data.onFocus}
games={games.data?.games games={games.data?.games
.map( .map(
(g) => (g) =>
@ -69,7 +70,7 @@ export function GameList (data: GameListParams)
title: g.name ?? "", title: g.name ?? "",
subtitle: ( subtitle: (
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
{!!g.path_platform_cover && <img className="size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />} {!!g.path_platform_cover && <img className="sm:hidden md:inline size-4" src={`${RPC_URL(__HOST__)}${g.path_platform_cover}`} />}
<p className="opacity-80">{g.platform_display_name}</p> <p className="opacity-80">{g.platform_display_name}</p>
</div> </div>
), ),

View file

@ -14,10 +14,6 @@ import
Bell, Bell,
Bluetooth, Bluetooth,
Clock, Clock,
Lock,
Power,
ShieldAlert,
Sun,
User, User,
Wifi, Wifi,
WifiHigh, WifiHigh,
@ -29,9 +25,10 @@ import { useQuery } from "@tanstack/react-query";
import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen"; import { getCurrentUserApiUsersMeGetOptions, statsApiStatsGetOptions } from "../../clients/romm/@tanstack/react-query.gen";
import { RPC_URL } from "../../shared/constants"; import { RPC_URL } from "../../shared/constants";
import { JSX, useEffect, useRef } from "react"; import { JSX, useEffect, useRef } from "react";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { SaveSource } from "../scripts/spatialNavigation"; import { SaveSource } from "../scripts/spatialNavigation";
import { systemApi } from "../scripts/clientApi"; import { systemApi } from "../scripts/clientApi";
import { twMerge } from "tailwind-merge";
function HeaderAvatar (data: { function HeaderAvatar (data: {
id: string; id: string;
@ -116,7 +113,7 @@ function NotificationStatus ()
{ {
const hasUnread = false; const hasUnread = false;
return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}> return <div className={classNames("p-2 rounded-full", { "bg-warning text-warning-content": hasUnread })}>
<Bell className="w-6 h-6" /> <Bell className="md:size-6 sm:size-4" />
</div>; </div>;
} }
@ -170,13 +167,13 @@ function BluetoothStatus ()
function WiFiStatus () function WiFiStatus ()
{ {
const { data: wifi } = useQuery({ const { data: wifi, isLoading } = useQuery({
queryKey: ['wifi'], queryKey: ['wifi'],
queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data), queryFn: () => systemApi.api.system.info.wifi.get().then(d => d.data),
refetchInterval: 3000 refetchInterval: 3000
}); });
return <div> return (!!wifi && wifi.length > 0) || isLoading ? <div>
{wifi?.map(w => {wifi?.map(w =>
{ {
const className = "w-6 h-6"; const className = "w-6 h-6";
@ -195,7 +192,7 @@ function WiFiStatus ()
</div>; </div>;
})} })}
</div>; </div> : undefined;
} }
function BatteryStatus () function BatteryStatus ()
@ -224,7 +221,7 @@ function BatteryStatus ()
batteryIcon = <BatteryMedium className={batteryClassName} />; batteryIcon = <BatteryMedium className={batteryClassName} />;
} }
} }
return <div className="flex gap-2 items-center"> return !!battery && battery.hasBattery && <div className="flex gap-2 items-center">
{batteryIcon} {batteryIcon}
<span className="font-semibold">{battery?.percent} %</span> <span className="font-semibold">{battery?.percent} %</span>
</div>; </div>;
@ -271,7 +268,9 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<header <header
ref={ref} ref={ref}
className="h-14 mt-2 flex items-center justify-between text-white" className={twMerge("md:relative md:h-14 md:mt-2 flex items-center justify-between text-white",
"sm:absolute sm:top-0 sm:right-0 sm:left-0"
)}
> >
<div className="flex items-center gap-2 drop-shadow-sm"> <div className="flex items-center gap-2 drop-shadow-sm">
{accounts?.map(a => <HeaderAvatar {accounts?.map(a => <HeaderAvatar
@ -285,8 +284,8 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
/>)} />)}
{data.title} {data.title}
</div> </div>
<div className="flex items-center gap-2 text drop-shadow-sm"> <div className="flex items-center md:gap-2 sm:gap-1 text drop-shadow-sm">
<div className="flex gap-5 items-center"> <div className="flex md:gap-5 sm:gap-2 items-center">
<ClockStatus /> <ClockStatus />
<WiFiStatus /> <WiFiStatus />
<BluetoothStatus /> <BluetoothStatus />
@ -297,7 +296,7 @@ export function HeaderUI (data: { buttons?: HeaderButton[]; accounts?: HeaderAcc
<div className="flex gap-2"> <div className="flex gap-2">
{data.buttonElements ?? data.buttons?.map(b => <RoundButton {data.buttonElements ?? data.buttons?.map(b => <RoundButton
key={b.id} key={b.id}
className="header-icon size-16" className="header-icon md:size-16 sm:size-10"
id={b.id} id={b.id}
icon={b.icon} icon={b.icon}
external={b.external} external={b.external}

View file

@ -7,8 +7,9 @@ import { rommApi } from "../scripts/clientApi";
import { SaveSource } from "../scripts/spatialNavigation"; import { SaveSource } from "../scripts/spatialNavigation";
import { JSX } from "react"; import { JSX } from "react";
import { HardDrive } from "lucide-react"; import { HardDrive } from "lucide-react";
import { GameCardFocusHandler } from "./GameCard";
export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: (node: HTMLElement) => void; }) export function PlatformsList (data: { id: string, setBackground: (url: string) => void; className?: string; onFocus?: GameCardFocusHandler; })
{ {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: platforms } = useSuspenseQuery( const { data: platforms } = useSuspenseQuery(
@ -29,7 +30,7 @@ export function PlatformsList (data: { id: string, setBackground: (url: string)
type="platform" type="platform"
id={data.id} id={data.id}
className={data.className} className={data.className}
onGameFocus={(id, node) => data.onFocus?.(node)} onGameFocus={data.onFocus}
games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime()) games={platforms.sort((a, b) => a.updated_at.getTime() - b.updated_at.getTime())
.map((g) => .map((g) =>
{ {

View file

@ -1,11 +1,11 @@
import React, { MouseEventHandler } from "react"; import { MouseEventHandler } from "react";
import SvgIcon, { IconType } from "./SvgIcon"; import SvgIcon, { IconType } from "./SvgIcon";
import classNames from "classnames"; import classNames from "classnames";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export default function ShortcutPrompt (data: { export default function ShortcutPrompt (data: {
id: string; id: string;
icon: IconType; icon?: IconType;
label?: string; label?: string;
className?: string; className?: string;
onClick?: MouseEventHandler; onClick?: MouseEventHandler;
@ -17,14 +17,15 @@ export default function ShortcutPrompt (data: {
style={{ viewTransitionName: data.id }} style={{ viewTransitionName: data.id }}
className={twMerge( className={twMerge(
"flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30", "flex md:gap-2 bg-base-100 text-base-content neutral-content md:pl-2 md:pr-3 md:py-1.5 rounded-full items-center md:text-lg drop-shadow-sm ring-[1px] ring-base-content/10 drop-shadow-black/30",
"sm:text-sm", "sm:text-sm sm:p-1",
"xs:text-xs sm:p-1",
data.className, data.className,
classNames({ classNames({
"hover:bg-base-300 cursor-pointer": !!data.onClick, "hover:bg-base-300 cursor-pointer": !!data.onClick,
}) })
)} )}
> >
<SvgIcon className="md:size-8 sm:size-6" icon={data.icon} /> {data.icon && <SvgIcon className="md:size-8 sm:size-6 xs:size-2" icon={data.icon} />}
{data.label} {data.label}
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { GamepadButtonEvent } from '../scripts/gamepads'; import useActiveControl, { GamepadButtonEvent } from '../scripts/gamepads';
import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts'; import { GamePadButtonCode, Shortcut } from '../scripts/shortcuts';
import ShortcutPrompt from './ShortcutPrompt'; import ShortcutPrompt from './ShortcutPrompt';
import { IconType } from './SvgIcon'; import { IconType } from './SvgIcon';
@ -23,16 +23,38 @@ const iconMap: Record<GamePadButtonCode, IconType> = {
[GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess' [GamePadButtonCode.Steam]: 'steamdeck_button_quickaccess'
}; };
const keyboardMap: Record<GamePadButtonCode, string> = {
[GamePadButtonCode.A]: 'ENTER',
[GamePadButtonCode.B]: 'ESC',
[GamePadButtonCode.X]: 'BACKSPACE',
[GamePadButtonCode.Y]: 'SPACE',
[GamePadButtonCode.L1]: 'Q',
[GamePadButtonCode.R1]: 'E',
[GamePadButtonCode.L2]: '',
[GamePadButtonCode.R2]: '',
[GamePadButtonCode.Select]: '',
[GamePadButtonCode.Start]: '',
[GamePadButtonCode.LJoy]: '',
[GamePadButtonCode.RJoy]: '',
[GamePadButtonCode.Up]: '',
[GamePadButtonCode.Down]: '',
[GamePadButtonCode.Left]: '',
[GamePadButtonCode.Right]: '',
[GamePadButtonCode.Steam]: ''
};
export default function Shortcuts (data: { shortcuts?: Shortcut[]; }) export default function Shortcuts (data: { shortcuts?: Shortcut[]; })
{ {
const { control } = useActiveControl();
const showKeyboard = control === 'keyboard' || control === 'mouse';
return ( return (
<div className="flex gap-2 z-1000"> <div className="flex gap-2 z-1000">
{data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt {data.shortcuts?.filter(s => !!s.label).map((s, i) => <ShortcutPrompt
key={s.button} key={s.button}
id={`shortcut-${s.button}`} id={`shortcut-${s.button}`}
onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))} onClick={e => s.action?.(new GamepadButtonEvent('gamepadbuttondown', { button: s.button, isClick: true }))}
icon={iconMap[s.button]} icon={showKeyboard ? undefined : iconMap[s.button]}
label={s.label} /> label={showKeyboard ? `${keyboardMap[s.button]} | ${s.label}` : s.label} />
)} )}
</div> </div>
); );

View file

@ -12,7 +12,7 @@ export function Button (data: {
children?: any, children?: any,
className?: string, className?: string,
disabled?: boolean, disabled?: boolean,
type: "reset" | "button" | "submit" | undefined; type?: "reset" | "button" | "submit";
shortcutLabel?: string; shortcutLabel?: string;
focusClassName?: string; focusClassName?: string;
} & InteractParams & FocusParams) } & InteractParams & FocusParams)

View file

@ -22,6 +22,7 @@ export default function DownloadDirectoryOption (data: PathSettingsOptionParams)
type={data.type} type={data.type}
save={setSettingMutation.mutate} save={setSettingMutation.mutate}
allowNewFolderCreation={data.allowNewFolderCreation} allowNewFolderCreation={data.allowNewFolderCreation}
requireConfirmation={data.requireConfirmation}
isDirectoryPicker={true} isDirectoryPicker={true}
localValue={localValue} localValue={localValue}
setLocalValue={(v) => setLocalValue={(v) =>

View file

@ -3,8 +3,7 @@
@plugin "daisyui"; @plugin "daisyui";
@theme { @theme {
--game-card-height: calc(var(--spacing) * 100); --breakpoint-xs: 20rem;
--game-card-width: calc(var(--spacing) * 64);
--animate-wiggle: wiggle 0.3s ease-in-out 1; --animate-wiggle: wiggle 0.3s ease-in-out 1;
--animate-rotate: rotate 0.3s ease-in-out 1 0.2s; --animate-rotate: rotate 0.3s ease-in-out 1 0.2s;
@ -107,6 +106,24 @@
} }
} }
@layer base {
@variant sm {
:root {
--game-card-height: calc(var(--spacing) * 55);
--game-card-width: calc(var(--spacing) * 35.2);
}
}
}
@layer base {
@variant md {
:root {
--game-card-height: calc(var(--spacing) * 100);
--game-card-width: calc(var(--spacing) * 64);
}
}
}
symbol path { symbol path {
fill: var(--color-base-content) !important; fill: var(--color-base-content) !important;
} }
@ -135,7 +152,7 @@ html {
} }
.menu-icon svg { .menu-icon svg {
@apply sm:size-7 md:size-9 transition-all; @apply sm:size-6 md:size-9 transition-all;
} }
.menu-icon.focus svg { .menu-icon.focus svg {
@ -143,7 +160,8 @@ html {
} }
.header-icon svg { .header-icon svg {
@apply w-8 h-8 min-w-8 min-h-8; @apply md:w-8 md:h-8 md:min-w-8 md:min-h-8;
@apply sm:w-5 sm:h-5 sm:min-w-5 sm:min-h-5;
} }
.header-icon-small svg { .header-icon-small svg {

View file

@ -9,6 +9,7 @@ import
Search, Search,
Power, Power,
OctagonAlert, OctagonAlert,
Maximize,
} from "lucide-react"; } from "lucide-react";
import import
{ {
@ -19,6 +20,7 @@ import { useMutation } from "@tanstack/react-query";
import import
{ {
FocusContext, FocusContext,
FocusDetails,
useFocusable, useFocusable,
} from "@noriginmedia/norigin-spatial-navigation"; } from "@noriginmedia/norigin-spatial-navigation";
import classNames from "classnames"; import classNames from "classnames";
@ -79,9 +81,14 @@ function HomeList (data: {
preferredChildFocusKey: `${data.selectedFilter}-list` preferredChildFocusKey: `${data.selectedFilter}-list`
}); });
const handleNodeFocus = (node: HTMLElement) => const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) =>
{ {
node.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' }); const isMounseEvent = details.nativeEvent instanceof MouseEvent;
if (!isMounseEvent)
{
node?.scrollIntoView({ inline: 'center', behavior: initFocus ? 'smooth' : 'instant' });
}
setInitFocus(true); setInitFocus(true);
}; };
@ -101,7 +108,7 @@ function HomeList (data: {
(ref.current as HTMLElement)?.scrollBy({ (ref.current as HTMLElement)?.scrollBy({
top: 0, top: 0,
left: deltaY, left: deltaY,
behavior: 'auto' behavior: 'instant'
}); });
} else } else
@ -109,7 +116,7 @@ function HomeList (data: {
(ref.current as HTMLElement)?.scrollBy({ (ref.current as HTMLElement)?.scrollBy({
top: 0, top: 0,
left: deltaY, left: deltaY,
behavior: 'auto' behavior: 'instant'
}); });
} }
}); });
@ -145,7 +152,9 @@ function MainMenu (data: {})
<ul <ul
ref={ref} ref={ref}
save-child-focus="session" save-child-focus="session"
className="flex items-center justify-center gap-3" className={twMerge("md:relative flex items-center justify-center md:gap-3",
"sm:gap-1 sm:absolute sm:bottom-2 sm:left-0 sm:right-0"
)}
> >
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<CircleIcon <CircleIcon
@ -199,7 +208,7 @@ function CircleIcon (data: {
onClick={data.action} onClick={data.action}
className={twMerge( className={twMerge(
`menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`, `menu-icon text-base-300 md:w-20 md:h-20 rounded-full flex items-center justify-center drop-shadow-lg cursor-pointer transition-all`,
'sm:w-14 sm:h-14', 'sm:w-14 sm:h-10',
typeClasses[data.type ?? "none"], classNames( typeClasses[data.type ?? "none"], classNames(
{ {
"focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused, "focus ring-7 ring-primary drop-shadow-2xl animate-scale": focused,
@ -263,18 +272,19 @@ export default function ConsoleHomeUI ()
<FocusContext.Provider value={focusKey}> <FocusContext.Provider value={focusKey}>
<div className="px-3 w-full pt-2"> <div className="px-3 w-full pt-2">
<HeaderUI buttons={[ <HeaderUI buttons={[
{ id: "fullscreen", icon: <Maximize />, action: () => document.documentElement.requestFullscreen() },
{ id: "search", icon: <Search /> }, { id: "search", icon: <Search /> },
{ id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() } { id: "power-button", icon: <Power />, external: true, action: () => closeMutation.mutate() }
]} /> ]} />
</div> </div>
<div className="flex w-full flex-col grow justify-evenly"> <div className="flex w-full flex-col grow justify-evenly md:pt-0">
<FilterUI <FilterUI
id="home" id="home"
options={filters} options={filters}
selected={filter ? filter : 'games'} selected={filter ? filter : 'games'}
setSelected={setFilter} setSelected={setFilter}
/> />
<div className="-mb-1"> <div className="md:-mb-1">
<HomeList <HomeList
selectedFilter={filter} selectedFilter={filter}
/> />
@ -283,7 +293,9 @@ export default function ConsoleHomeUI ()
<MainMenu /> <MainMenu />
</div> </div>
</div> </div>
<footer className="px-2 pb-2 flex items-center justify-between h-12"> <footer className={twMerge("md:relative px-2 md:pb-2 flex items-center justify-between h-12",
"sm:absolute bottom-0 left-0 right-0"
)}>
<div className="flex gap-2 text-sm"> <div className="flex gap-2 text-sm">
</div> </div>
<Shortcuts shortcuts={shortcuts} /> <Shortcuts shortcuts={shortcuts} />

View file

@ -1,5 +1,5 @@
import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation'; import { FocusContext, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { Block, createFileRoute, useBlocker } from '@tanstack/react-router'; import { Block, createFileRoute } from '@tanstack/react-router';
import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption'; import DownloadDirectoryOption from '@/mainview/components/options/DownloadDirectoryOption';
import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query'; import { useIsMutating, useMutation, useQuery } from '@tanstack/react-query';
import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries'; import { changeDownloadsMutation, downloadDrivesQuery } from '@/mainview/scripts/queries';
@ -7,12 +7,12 @@ import { DownloadsDrive } from '@/shared/constants';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
import classNames from 'classnames'; import classNames from 'classnames';
import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts'; import { GamePadButtonCode, Shortcut, useShortcuts } from '@/mainview/scripts/shortcuts';
import { Download, FolderOpen, HardDrive, Usb } from 'lucide-react'; import { Download, FolderOpen, HardDrive, Save, Usb } from 'lucide-react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { OptionSpace } from '@/mainview/components/options/OptionSpace'; import { OptionSpace } from '@/mainview/components/options/OptionSpace';
import data from '@emulators';
import { Button } from '@/mainview/components/options/Button'; import { Button } from '@/mainview/components/options/Button';
import { systemApi } from '@/mainview/scripts/clientApi'; import { systemApi } from '@/mainview/scripts/clientApi';
import useActiveControl from '@/mainview/scripts/gamepads';
export const Route = createFileRoute('/settings/directories')({ export const Route = createFileRoute('/settings/directories')({
component: RouteComponent, component: RouteComponent,
@ -20,27 +20,33 @@ export const Route = createFileRoute('/settings/directories')({
function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; }) function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; refetchDrives: () => void; })
{ {
const { ref, focused, focusKey } = useFocusable({ focusKey: data.drive.device }); const { ref, focused, focusKey } = useFocusable({
focusKey: data.drive.device,
onFocus: () => (ref.current as HTMLElement)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
});
const isMoving = useIsMutating(changeDownloadsMutation); const isMoving = useIsMutating(changeDownloadsMutation);
const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0); const usedWithoutDownlods = data.drive.used - (data.drive.isCurrentlyUsed ? data.downloadsSize : 0);
const usedPercent = usedWithoutDownlods / data.drive.size; const usedPercent = usedWithoutDownlods / data.drive.size;
const usedPercentRaw = data.drive.used / data.drive.size; const usedPercentRaw = data.drive.used / data.drive.size;
const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason; const changeDownloads = useMutation({ ...changeDownloadsMutation, onSuccess: data.refetchDrives }); data.drive.unusableReason;
const shortcuts: Shortcut[] = []; const shortcuts: Shortcut[] = [];
if (!data.drive.unusableReason && isMoving <= 0) const valid = !data.drive.unusableReason && isMoving <= 0;
const handleAction = () => changeDownloads.mutate(data.drive.mountPoint);
if (valid)
{ {
shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: () => changeDownloads.mutate(data.drive.mountPoint) }); shortcuts.push({ label: "Move Downloads", button: GamePadButtonCode.A, action: handleAction });
} }
useShortcuts(focusKey, () => shortcuts, [shortcuts]); useShortcuts(focusKey, () => shortcuts, [shortcuts]);
const { isMouse } = useActiveControl();
return <li ref={ref} className={twMerge('flex flex-row p-4 bg-base-300 rounded-2xl gap-1 items-end',
return <li ref={ref} className={twMerge('flex flex-col p-4 bg-base-300 rounded-2xl gap-1',
classNames({ classNames({
"ring-7": focused, "ring-7": focused,
"border-dashed border-primary border-7": data.drive.isCurrentlyUsed, "border-dashed border-primary border-4": data.drive.isCurrentlyUsed,
"border-solid": data.drive.unusableReason === 'already_used', "border-solid": data.drive.unusableReason === 'already_used',
"ring-error": data.drive.unusableReason === 'not_enough_space', "ring-error": data.drive.unusableReason === 'not_enough_space',
}))}> }))}>
<div className='flex flex-col grow gap-1'>
<div className='flex gap-2 font-semibold'>{data.drive.isRemovable ? <Usb /> : <HardDrive />}{data.drive.label}</div> <div className='flex gap-2 font-semibold'>{data.drive.isRemovable ? <Usb /> : <HardDrive />}{data.drive.label}</div>
<small className='opacity-60'>{data.drive.mountPoint}</small> <small className='opacity-60'>{data.drive.mountPoint}</small>
<div className='flex gap-2'> <div className='flex gap-2'>
@ -60,6 +66,8 @@ function DriveComponent (data: { drive: DownloadsDrive; downloadsSize: number; r
}))} style={{ width: usedPercent.toLocaleString('en-US', { style: 'percent' }) }}></div> }))} style={{ width: usedPercent.toLocaleString('en-US', { style: 'percent' }) }}></div>
{!!data.drive.isCurrentlyUsed && <div className="h-full bg-base-content" style={{ width: usedPercentRaw.toLocaleString('en-US', { style: 'percent' }) }}></div>} {!!data.drive.isCurrentlyUsed && <div className="h-full bg-base-content" style={{ width: usedPercentRaw.toLocaleString('en-US', { style: 'percent' }) }}></div>}
</div> </div>
</div>
{valid && isMouse && <Button type="button" className='btn-circle' onAction={handleAction} id={`${data.drive.mountPoint}-select`}><Save /></Button>}
</li>; </li>;
} }
@ -77,7 +85,7 @@ function RouteComponent ()
<Block shouldBlockFn={() => isMoving} withResolver={false} /> <Block shouldBlockFn={() => isMoving} withResolver={false} />
<ul ref={ref} className="list rounded-box gap-2"> <ul ref={ref} className="list rounded-box gap-2">
<div className="divider text-2xl mt-0 md:mt-4"> <div className="divider text-2xl mt-0 md:mt-4">
<Download className='size-16' /> Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : '?'}) <Download className='size-16' /> Downloads ({drives?.downloadsSize ? prettyBytes(drives?.downloadsSize) : <span className="loading loading-spinner loading-lg size-6"></span>})
</div> </div>
<ul className='p-2 grid grid-cols-2 gap-3'> <ul className='p-2 grid grid-cols-2 gap-3'>
{drives?.drives.filter(d => d.mountPoint).map(d => <DriveComponent refetchDrives={refetch} downloadsSize={drives.downloadsSize} drive={d} />)} {drives?.drives.filter(d => d.mountPoint).map(d => <DriveComponent refetchDrives={refetch} downloadsSize={drives.downloadsSize} drive={d} />)}

View file

@ -29,6 +29,7 @@ import { PopSource } from "../../scripts/spatialNavigation";
import { Router } from "../.."; import { Router } from "../..";
import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts";
import Shortcuts from "@/mainview/components/Shortcuts"; import Shortcuts from "@/mainview/components/Shortcuts";
import useActiveControl from "@/mainview/scripts/gamepads";
export const Route = createFileRoute("/settings")({ export const Route = createFileRoute("/settings")({
component: SettingsUI, component: SettingsUI,
@ -68,6 +69,7 @@ function MenuItem (data: {
? handleNonFocusSelect ? handleNonFocusSelect
: undefined, : undefined,
}); });
const { isMouse } = useActiveControl();
return ( return (
<li <li
ref={ref} ref={ref}
@ -81,13 +83,13 @@ function MenuItem (data: {
"group rounded-full p-3 pl-5 text-base-content/80", "group rounded-full p-3 pl-5 text-base-content/80",
classNames({ classNames({
"bg-primary text-primary-content": acitve, "bg-primary text-primary-content": acitve,
"font-semibold ring-7 ring-primary-content": focused, "font-semibold ring-7 ring-primary-content": focused && !isMouse,
"bg-secondary text-secondary-content ring-primary": data.return && focused, "bg-secondary text-secondary-content ring-primary": data.return && focused,
}), }),
data.linkClassName, data.linkClassName,
)} )}
> >
<div className={twMerge("flex gap-2 items-center transition-all group-hover:scale-110", classNames({ <div className={twMerge("flex gap-2 items-center transition-all", classNames({
"scale-110": focused || acitve "scale-110": focused || acitve
}))}> }))}>
{data.icon} {data.icon}

View file

@ -1,7 +1,13 @@
import { getCurrentFocusKey, navigateByDirection, SpatialNavigation } from "@noriginmedia/norigin-spatial-navigation"; import { getCurrentFocusKey, navigateByDirection } from "@noriginmedia/norigin-spatial-navigation";
import { dispatchFocusedEvent, GetFocusedElement } from "./spatialNavigation"; import { GetFocusedElement } from "./spatialNavigation";
import { useEffect, useState } from "react";
let loopStarted = false; let loopStarted = false;
let isTouching = false;
type ActiveControlType = 'keyboard' | 'gamepad' | 'mouse' | 'touch' | undefined;
let activeControls: ActiveControlType = undefined;
let mouseUpdateTimeout: any | undefined = undefined;
let touchStopTimeout: any | undefined = undefined;
const handleLoop = () => const handleLoop = () =>
{ {
@ -11,8 +17,70 @@ const handleLoop = () =>
loopStarted = true; loopStarted = true;
} }
}; };
// Mouse needs to be delayed so that touch events can cancel it.
// This is to prevent both touch and mouse events triggering as they do on the steam deck.
const handleMouseMove = (e: MouseEvent) =>
{
if (!mouseUpdateTimeout && !isTouching)
{
mouseUpdateTimeout = setTimeout(() =>
{
focusControl('mouse');
mouseUpdateTimeout = undefined;
}, 300);
}
};
function clearMouseUpdate ()
{
if (mouseUpdateTimeout)
clearTimeout(mouseUpdateTimeout);
mouseUpdateTimeout = undefined;
};
const handleKeyDown = () =>
{
focusControl('keyboard');
};
const handleTouchStart = (e: TouchEvent) =>
{
isTouching = true;
focusControl('touch');
clearMouseUpdate();
};
const handleTouchEnd = (e: TouchEvent) =>
{
setTimeout(() => isTouching = false, 10);
};
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchEnd);
window.addEventListener("gamepadconnected", handleLoop); window.addEventListener("gamepadconnected", handleLoop);
import.meta.hot.dispose(() => window.addEventListener('gamepaddisconnected', handleLoop)); window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('keydown', handleKeyDown);
import.meta.hot.dispose(() => window.removeEventListener('gamepaddisconnected', handleLoop));
import.meta.hot.dispose(() => window.removeEventListener('mousemove', handleMouseMove));
import.meta.hot.dispose(() => window.removeEventListener('keydown', handleKeyDown));
import.meta.hot.dispose(() => window.removeEventListener('touchstart', handleTouchStart));
import.meta.hot.dispose(() => window.removeEventListener('touchend', handleTouchEnd));
import.meta.hot.dispose(() => window.removeEventListener('touchcancel', handleTouchEnd));
export default function useActiveControl ()
{
const [c, setC] = useState<typeof activeControls>(activeControls);
useEffect(() =>
{
const handler = (e: Event) => setC((e as CustomEvent).detail);
window.addEventListener('activecontrolschange', handler);
return () => window.removeEventListener('activecontrolschange', handler);
});
return { isMouse: c === 'mouse', isPointer: c === 'mouse' || c === 'touch', control: c };
}
const throttleMap = new Map<string, number>(); const throttleMap = new Map<string, number>();
const throttleAcceleration = new Map<string, number>(); const throttleAcceleration = new Map<string, number>();
@ -32,6 +100,19 @@ function throttleNav (key: string, dir: string, event: Event)
} }
} }
function focusControl (control: typeof activeControls)
{
if (activeControls != control)
{
activeControls = control;
window.dispatchEvent(new CustomEvent('activecontrolschange', { detail: control }));
if (control !== 'mouse')
{
clearMouseUpdate();
}
}
}
/*window.addEventListener('keydown', e => /*window.addEventListener('keydown', e =>
{ {
if (e.key === 'Escape') if (e.key === 'Escape')
@ -74,6 +155,7 @@ function updateStatus ()
if (!throttleMap.has(key)) if (!throttleMap.has(key))
{ {
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad })); window.dispatchEvent(new GamepadButtonEvent('gamepadbuttondown', { button: i, gamepad: gamepad }));
focusControl('gamepad');
throttleMap.set(key, 0); throttleMap.set(key, 0);
} }
} else } else
@ -81,6 +163,7 @@ function updateStatus ()
if (throttleMap.delete(key)) if (throttleMap.delete(key))
{ {
window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad })); window.dispatchEvent(new GamepadButtonEvent('gamepadbuttonup', { button: i, gamepad: gamepad }));
focusControl('gamepad');
} }
} }
} }

View file

@ -1,4 +1,5 @@
import { FocusDetails } from '@noriginmedia/norigin-spatial-navigation';
import { JSX } from 'react'; import { JSX } from 'react';
import * as z from 'zod'; import * as z from 'zod';
@ -14,7 +15,7 @@ export interface GameMeta
{ {
id: string, id: string,
onSelect?: () => void, onSelect?: () => void,
onFocus?: () => void, onFocus?: (details: FocusDetails) => void,
title: string, title: string,
subtitle: string | JSX.Element, subtitle: string | JSX.Element,
previewUrl?: string; previewUrl?: string;

View file

@ -7,7 +7,7 @@ import path from "node:path";
import staticAssetsPlugin from 'vite-static-assets-plugin'; import staticAssetsPlugin from 'vite-static-assets-plugin';
import os from 'node:os'; import os from 'node:os';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
import { host } from "@/bun/utils/host"; import { host } from "./src/bun/utils/host";
export default defineConfig(() => export default defineConfig(() =>
{ {