import { JSX, Suspense, useContext, useState } from "react"; import { Gamepad2, Settings, Search, Power, OctagonAlert, Maximize, Store, LayoutGrid, LucideIcon, } from "lucide-react"; import { createFileRoute, useRouter, } from "@tanstack/react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { FocusContext, FocusDetails, useFocusable, } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { useEventListener } from "usehooks-ts"; import { HeaderAccounts, HeaderButton, HeaderStatusBar } from "../components/Header"; import { FilterUI } from "../components/Filters"; import { AnimatedBackground } from "../components/AnimatedBackground"; import { GameList } from "../components/GameList"; import LoadingCardList from "../components/LoadingCardList"; import { AutoFocus } from "../components/AutoFocus"; import SaveScroll from "../components/SaveScroll"; import { ErrorBoundary, useErrorBoundary } from "react-error-boundary"; import { twMerge } from "tailwind-merge"; import { PlatformsList } from "../components/PlatformsList"; import { GamePadButtonCode, useShortcuts } from "../scripts/shortcuts"; import z from "zod"; import CollectionList from "../components/CollectionList"; import { zodValidator } from '@tanstack/zod-adapter'; import { mobileCheck, scrollIntoViewHandler, useDragScroll } from "../scripts/utils"; import { AnimatedBackgroundContext } from "../scripts/contexts"; import Carousel from "../components/Carousel"; import { closeMutation } from "@queries/system"; import { gameQuery } from "../scripts/queries/romm"; import { oneShot } from "../scripts/audio/audio"; import { FloatingShortcuts } from "../components/Shortcuts"; import SelectMenu from "../components/SelectMenu"; import HeaderSearchField from "../components/HeaderSearchField"; import CardElement from "../components/CardElement"; import { Router } from ".."; import { FrontEndId } from "@/shared/types"; export const Route = createFileRoute("/")({ component: ConsoleHomeUI, validateSearch: zodValidator(z.object({ filter: z.string().optional().default('games') })) }); const filters = { consoles: { label: "Consoles", }, games: { label: "Games", }, collections: { label: "Collections", }, }; let screenLock: WakeLockSentinel | undefined = undefined; async function handleFullscreen () { if (document.fullscreenElement) { await document.exitFullscreen(); if (screenLock) screenLock.release(); } else { await document.documentElement.requestFullscreen(); screenLock = await navigator.wakeLock.request('screen'); return screenLock; } } function HomeListError (data: { focused: boolean; }) { const error = useErrorBoundary(); return
{(error.error as any).detail}
; } function Preview (data: { index: number; children?: any; }) { const isMobile = mobileCheck(); return
{data.children}
; } function AdditionalCard (data: { id: string, route: keyof typeof Router.routesByPath, title: string, subTitle: string, index: number, actionLabel: string; icon: LucideIcon | string; badgeIcon?: LucideIcon; }) { const router = useRouter(); const handleNavigate = () => { router.navigate({ to: data.route as any }); }; useShortcuts(data.id, () => [{ label: data.actionLabel, button: GamePadButtonCode.A, action: handleNavigate }]); return ] : undefined} onAction={handleNavigate} title={data.title} subtitle={data.subTitle} preview={ {typeof data.icon === 'string' ? : } } focusKey={data.id} index={0} id={data.id} />; } function HomeList (data: { selectedFilter: string; }) { const router = useRouter(); const queryClient = useQueryClient(); const [initFocus, setInitFocus] = useState(false); const bg = useContext(AnimatedBackgroundContext); const { } = Route.useSearch; const { ref, focused, focusKey, focusSelf } = useFocusable({ focusKey: "home-list", preferredChildFocusKey: `${data.selectedFilter}-list` }); const handleNodeFocus = (id: string, node: HTMLElement, details: FocusDetails) => { const isMouseEvent = details.nativeEvent instanceof MouseEvent; if (!isMouseEvent) { node?.scrollIntoView({ inline: 'center', block: 'center', behavior: initFocus ? 'smooth' : 'instant' }); } setInitFocus(true); }; function handleGameSelect (id: FrontEndId, source: string | null, sourceId: string | null) { router.navigate({ to: '/game/$source/$id', params: { id: String(sourceId ?? id.id), source: source ?? id.source } }); }; let activeList: JSX.Element; switch (data.selectedFilter) { case 'consoles': activeList = <> }> ; break; case 'collections': activeList = <> ; break; default: activeList = <> { const [source, id] = d.id?.split('@', 2); queryClient.prefetchQuery(gameQuery(source, id)); handleNodeFocus(l, n, d); }} className="animate-slide-up" key="games-list" id="games-list" setBackground={bg.setBackground} filters={{ limit: 12, orderBy: 'activity' }} finalElement={[ , ]} emptyElement={[ ]} /> ; break; } useEventListener('wheel', e => { const deltaY = e.deltaY; const deltaYSign = Math.sign(e.deltaY); if (deltaYSign == -1) { (ref.current as HTMLElement)?.scrollBy({ top: 0, left: deltaY, behavior: 'instant' }); } else { (ref.current as HTMLElement)?.scrollBy({ top: 0, left: deltaY, behavior: 'instant' }); } }); useDragScroll(ref); return (
}> }> {activeList}
); } function MainMenu () { const router = useRouter(); const { ref, focusKey } = useFocusable({ focusKey: `main-menu`, trackChildren: true, focusBoundaryDirections: ['up', 'down'] }); return (
    router.navigate({ to: "/games", state: { eventType: e?.event?.type } })} icon={} label="Home" type="secondary" /> } onAction={(e) => router.navigate({ to: "/store/tab", state: { eventType: e?.event?.type } })} label="Shop" /> { router.navigate({ to: '/settings/interface', state: { eventType: e?.event?.type } }); }} icon={} label="Settings" type="accent" />
); } function CircleIcon (data: { type?: "secondary" | "accent" | "info"; label?: string; icon?: JSX.Element; } & InteractParams) { const handleAction = (event?: Event) => { data.onAction?.({ event, focusKey }); oneShot('click'); }; const { ref, focusKey } = useFocusable({ focusKey: `menu-navigation-icon-${data.label}`, onEnterPress: handleAction, }); useShortcuts(focusKey, () => [{ label: data.label, action: handleAction, button: GamePadButtonCode.A }]); const typeClasses = { secondary: "bg-secondary text-secondary-content", accent: "bg-accent text-accent-content", info: "bg-info text-info-content", none: "bg-base-content", }; return (
  • handleAction(e.nativeEvent)} className={twMerge( `portrait:sm:size-12 sm:w-14 sm:h-10 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 focusable focusable-primary focused:drop-shadow-2xl focused:animate-scale focusable-hover bg-base-content border-6 md:border-12 border-base-content focused:border-0 hover:border-0 z-1 active:border-0 active:bg-base-300 active:text-base-content active:transition-none`, typeClasses[data.type ?? 'none'])} >
    {data.icon}
  • ); } export default function ConsoleHomeUI () { const { filter } = Route.useSearch(); const close = useMutation(closeMutation); const router = useRouter(); const { ref, focusKey } = useFocusable({ forceFocus: true, autoRestoreFocus: false, saveLastFocusedChild: false, focusKey: "HomePage", preferredChildFocusKey: `home-list`, }); const setFilter = (filter: string) => router.navigate({ to: '/', search: { filter }, viewTransition: false, replace: true }); const headerButtons: HeaderButton[] = []; if (mobileCheck()) headerButtons.push({ id: "fullscreen", icon: , action: handleFullscreen }); headerButtons.push( { id: "power-button", icon: , external: true, action: () => close.mutate(), className: "focusable-error!" }, { id: "settings-header-button", icon: , external: true, action: () => router.navigate({ to: "/settings/accounts" }) } ); const handleSearch = (search: string | undefined) => { router.navigate({ to: '/games', search: { search } }); }; return (
    [key, { ...value, selected: key === filter }]))} setSelected={setFilter} />
    } />
    ); }