import { createFileRoute } from "@tanstack/react-router"; import { FrontEndGameTypeDetailed, GameInstallProgress, GameStatusType, RPC_URL } from "@shared/constants"; import { twJoin, twMerge } from "tailwind-merge"; import { JSX, RefObject, useEffect, useRef, useState } from "react"; import { FocusContext, setFocus, useFocusable } from "@noriginmedia/norigin-spatial-navigation"; import classNames from "classnames"; import { Clock, CloudDownload, Download, Folder, HardDrive, Image, PackageOpen, Play, Settings, Store, Trash, TriangleAlert, Trophy } from "lucide-react"; import { HeaderUI } from "../../components/Header"; import prettyBytes from 'pretty-bytes'; import { useEventListener } from "usehooks-ts"; import { PopSource, SaveSource, useFocusEventListener } from "../../scripts/spatialNavigation"; import { AnimatedBackground } from "../../components/AnimatedBackground"; import { rommApi } from "../../scripts/clientApi"; import toast from "react-hot-toast"; import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Router } from "../.."; import { ContextDialog, ContextList, DialogEntry } from "../../components/ContextDialog"; import Shortcuts from "../../components/Shortcuts"; import { GamePadButtonCode, useShortcutContext, useShortcuts } from "@/mainview/scripts/shortcuts"; const gameQuery = (source: string, id: number) => queryOptions({ queryKey: ['game', source, id], queryFn: async () => { const { data, error } = await rommApi.api.romm.game({ source })({ id }).get(); if (error) throw error; return data; } }); export const Route = createFileRoute("/game/$source/$id")({ loader: ({ params, context }) => { context.queryClient.prefetchQuery(gameQuery(params.source, Number(params.id))); }, component: GameDetailsUI, pendingComponent: GameDetailsUIPending, }); function GameDetailsUIPending () { return
; } function HandleGoBack () { const source = PopSource('details'); Router.navigate({ to: source ?? '/', viewTransition: { types: ['zoom-out'] } }); } function Details (data: { mainAreaRef: RefObject, game?: FrontEndGameTypeDetailed; }) { const { ref, focusKey } = useFocusable({ focusKey: 'main-details', onFocus: () => { data.mainAreaRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }, preferredChildFocusKey: "play-btn", saveLastFocusedChild: false }); const platformCoverImg = `${RPC_URL(__HOST__)}${data.game?.path_platform_cover}`; const gameCoverImg = data.game?.path_cover ? `${RPC_URL(__HOST__)}${data.game?.path_cover}` : undefined; let fileSizeIcon: JSX.Element | undefined; if (!data.game) { fileSizeIcon = ; } else if (data.game.missing) { fileSizeIcon = ; } else if (data.game.local) { fileSizeIcon = ; } else { fileSizeIcon = ; } return
{gameCoverImg ? :
}
} >{data.game?.last_played ? new Date(data.game.last_played).toDateString() : "Never"} {!!data.game && (data.game.fs_size_bytes !== null || data.game.missing) &&
{data.game.missing ? 'Missing' : prettyBytes(data.game.fs_size_bytes!)}
} } >{data.game?.platform_display_name ??
}
} > {data.game?.source ?? data.game?.id.source} {data.game?.local && local}
{data.game?.summary ??
}
{!!data.game && }
; } function Screenshot (data: { path: string; index: number; setFocused?: (index: number) => void; }) { const { ref, focused, focusSelf } = useFocusable({ focusKey: `screenshot-${data.index}`, onFocus: () => { (ref.current as HTMLElement).scrollIntoView({ inline: 'center', behavior: 'smooth' }); data.setFocused?.(data.index); } }); 4096; return ; } function Screenshots (data: { screenshots: string[]; }) { const scrollRef = useRef(null); const [focusedScreenshot, setFocusedScreenshot] = useState(-1); const { ref, focusKey } = useFocusable({ focusKey: 'screenshot-list', onFocus: () => (ref.current as HTMLElement).scrollIntoView({ block: 'center', behavior: 'smooth' }), onBlur: () => setFocusedScreenshot(-1) }); return
{data.screenshots.map((s, i) => )}
{data.screenshots.map((s, i) => { const focused = i === focusedScreenshot; return ; })}
; } function AchievementsInfo (data: { game: FrontEndGameTypeDetailed; }) { if (!data.game.achievements) { return false; } return
{`${data.game.achievements.unlocked}/${data.game.achievements.total}`}
; } function MainActions (data: { game: FrontEndGameTypeDetailed; }) { const { source, id } = Route.useParams(); const installMutation = useMutation({ mutationKey: ['install'], mutationFn: async () => { const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).install.post(); if (error) throw error; } }); const playMutation = useMutation({ mutationKey: ['play'], mutationFn: async () => { const { error } = await rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).play.post(); if (error) throw error; } }); const [progress, setProgress] = useState(undefined); const [status, setStatus] = useState(undefined); const [error, setError] = useState(undefined); const [details, setDetails] = useState(undefined); const queryClient = useQueryClient(); useEffect(() => { const es = new EventSource(`${RPC_URL(__HOST__)}/api/romm/status/${data.game.id.source}/${data.game.id.id}`); es.onmessage = ({ data }) => { const stats = JSON.parse(data) as GameInstallProgress; setProgress(stats.progress); setStatus(stats.status); setDetails(stats.details); setError(stats.error); }; es.addEventListener('refresh', () => { queryClient.invalidateQueries({ queryKey: ['game', data.game.id] }); location.reload(); }); es.addEventListener('error', (e) => { if ((e as any).data) { const stats = JSON.parse((e as any).data) as GameInstallProgress; toast.error(stats.error); } }); es.onerror = (event) => { const error = (event as any).data?.error; if (error) { toast.error(error); } }; return () => es.close(); }, [data.game.id]); let progressIcon: JSX.Element | undefined = undefined; switch (status) { case 'download': progressIcon = ; break; case 'extract': progressIcon = ; break; } let mainButton: JSX.Element | undefined = undefined; if (status === 'installed') { mainButton = { playMutation.mutate(); SaveSource('launch'); Router.navigate({ to: '/launcher/$source/$id', viewTransition: { types: ['zoom-in'] }, params: { source, id } }); }} tooltip={details} key="primary" type='primary' id="mainAction">; } else if (error) { mainButton = { if (status === 'missing-emulator') { SaveSource('settings'); Router.navigate({ to: '/settings/directories', viewTransition: { types: ['zoom-in'] } }); } }} id="mainAction"> ; } else { mainButton = { if (status === 'install') { installMutation.mutate(); } }} tooltip={details ?? status} type='primary' id="mainAction"> {status === 'install' ? : } ; } return
{mainButton}
{progress !== null && !!progressIcon &&
{progressIcon}
}
; } function ActionButtons (data: { game: FrontEndGameTypeDetailed; }) { const [hoverText, setHoverText] = useState(undefined); const [hoverTextType, setHoverTextType] = useState('accent'); const { ref, focusKey } = useFocusable({ focusKey: 'actions', onBlur: () => setHoverText(undefined) }); const [open, setOpen] = useState(false); const deleteMutation = useMutation({ mutationKey: ['delete', data.game.id], mutationFn: () => rommApi.api.romm.game({ source: data.game.id.source })({ id: data.game.id.id }).delete(), onSuccess: () => { location.reload(); console.log("Deleted"); } }); const contextOptions: DialogEntry[] = []; if (data.game.local) { contextOptions.push({ id: 'delete', action: () => { deleteMutation.mutate(); }, icon: , content: "Delete", type: 'error' }); } const handleTooltipSet = (e: HTMLElement) => { const dataTooltip = e.getAttribute('data-tooltip'); setHoverText(dataTooltip ?? undefined); setHoverTextType(e.getAttribute('data-tooltip_type') ?? 'accent'); }; useFocusEventListener('focuschanged', (e) => { if (e.target instanceof HTMLElement) { handleTooltipSet(e.target); } }, ref); const tooltipStyles = { base: 'bg-base-100 text-base-content', accent: 'bg-accent text-accent-content', error: 'bg-error text-error-content' }; return
setOpen(true)} type="base" id="settings" icon={} > { setOpen(false); setFocus("settings"); }}> {!!hoverText &&

{hoverText}

}
; } function Detail (data: { icon: JSX.Element; children?: any | any[]; }) { return (
{data.icon} {data.children}
); } function ActionButton (data: { id: string, icon?: JSX.Element, children?: any | any[]; className?: string; type: "primary" | 'base' | "accent" | 'error'; square?: boolean, onFocus?: () => void; tooltip?: string, tooltip_type?: 'accent' | 'error'; onAction?: () => void; disabled?: boolean; }) { const { ref, focused } = useFocusable({ focusKey: data.id, onFocus: data.onFocus, onEnterPress: data.onAction, focusable: data.disabled !== true }); const styles = { primary: twMerge("bg-primary text-primary-content", classNames({ "bg-base-content text-base-300 ring-7 ring-primary": focused })), base: twMerge(" text-base-content border-dashed border-base-content/20 border-2", classNames({ "bg-base-content text-base-300 ring-7 ring-primary": focused })), accent: twMerge("bg-primary text-primary-content ", classNames({ "bg-base-content text-base-300 ring-7 ring-primary": focused })), error: twMerge("bg-error text-error-content ", classNames({ "bg-error text-error-content ring-7 ring-primary": focused })), }; return ( ); } export default function GameDetailsUI () { const { source, id } = Route.useParams(); const { data, isSuccess } = useQuery(gameQuery(source, Number(id))); const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details" }); const backgroundImage = data?.path_cover ? `${RPC_URL(__HOST__)}${data?.path_cover}` : undefined; const mainAreaRef = useRef(null); useShortcuts(focusKey, () => [{ label: "Back", button: GamePadButtonCode.B, action: HandleGoBack }]); const { shortcuts } = useShortcutContext(); useEffect(() => { if (isSuccess) { focusSelf(); } }, [isSuccess]); return (
Screenshots
{!!data && }
); }