feat: Implemented dolphin integration

This commit is contained in:
Simeon Radivoev 2026-04-02 14:20:30 +03:00
parent edbc390d14
commit a69147a4f7
Signed by: simeonradivoev
GPG key ID: 7611A451D2A5D37A
24 changed files with 220 additions and 59 deletions

View file

@ -70,6 +70,7 @@ export interface HeaderButton
icon: JSX.Element;
external?: boolean;
action?: () => void;
className?: string;
}
export interface HeaderAccount
@ -247,25 +248,28 @@ export function HeaderAccounts (data: { accounts?: HeaderAccount[]; })
export function HeaderStatusBar (data: { buttons?: HeaderButton[]; buttonElements?: JSX.Element[] | JSX.Element; })
{
return <div className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
<ClockStatus />
<WiFiStatus />
<BluetoothStatus />
<NotificationStatus />
<BatteryStatus />
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
<div className="flex gap-2">
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
key={b.id}
className="header-icon sm:size-10 md:size-14"
id={b.id}
external={b.external}
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
onAction={b.action}
>{b.icon}</RoundButton>)}
</div>
const { ref, focusKey } = useFocusable({ focusKey: 'header-status-bar' });
return <div ref={ref} className="flex items-center sm:gap-1 md:gap-2 text drop-shadow-sm">
<FocusContext value={focusKey}>
<div className="flex sm:gap-2 md:gap-5 items-center" style={{ viewTransitionName: 'status-bar-icons' }}>
<ClockStatus />
<WiFiStatus />
<BluetoothStatus />
<NotificationStatus />
<BatteryStatus />
</div>
{!!data.buttons && <div className="divider divider-horizontal mx-0"></div>}
<div className="flex gap-2">
{data.buttonElements ?? data.buttons?.map(b => <RoundButton
key={b.id}
className={twMerge("header-icon sm:size-10 md:size-14", b.className)}
id={b.id}
external={b.external}
cssStyle={{ viewTransitionName: `header-button-${b.id}` }}
onAction={b.action}
>{b.icon}</RoundButton>)}
</div>
</FocusContext>
</div>;
}
@ -296,13 +300,13 @@ export function HeaderUI (data: HeaderUIParams)
>
<HeaderAccounts key={"header-accounts"} accounts={data.accounts} />
{data.title}
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "settings", action: goToSettings, external: true }]} />
<HeaderStatusBar key={"header-status-bar"} buttonElements={data.buttonElements} buttons={[...data.buttons ?? [], { icon: <Settings />, id: "header-settings-btn", action: goToSettings, external: true }]} />
</header>
</FocusContext.Provider>
);
}
export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
export function StickyHeaderUI (data: { ref: RefObject<any>; className?: string; } & HeaderUIParams)
{
const [isStuck, setIsStuck] = useState(false);
const headerRef = useRef(null);
@ -311,7 +315,7 @@ export function StickyHeaderUI (data: { ref: RefObject<any>; } & HeaderUIParams)
return <>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className='sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15'>
<div ref={headerRef} className={twMerge('sticky not-mobile:data-stuck:backdrop-blur-xl transition-all top-0 px-2 p-2 not-data-stuck:bg-base-200 mobile:bg-base-300 z-15', data.className)}>
<HeaderUI focusable={!isStuck} {...data} />
</div>
</>;

View file

@ -0,0 +1,10 @@
import { ErrorComponentProps } from "@tanstack/react-router";
import { TriangleAlert } from "lucide-react";
export default function Error (data: ErrorComponentProps)
{
return <div className='flex flex-col w-full gap-2 h-64 items-center justify-center'>
<div className='flex gap-2 font-bold text-2xl text-error'><TriangleAlert />Invalid Store. Update App.</div>
<div className='text-base-content/40'>{data.error.message}</div>
</div>;
}

View file

@ -81,8 +81,8 @@ export function StoreEmulatorCard (data: {
</div>
<div className="flex gap-1 mt-1 h-10 items-center">
{!!data.emulator.integration && data.emulator.validSources.some(s => s.type === 'store') && <div className="tooltip tooltip-primary" data-tip="Has Integration">
<div className="bg-primary text-primary-content rounded-full p-1"><WandSparkles /></div>
{!!data.emulator.integration && <div aria-disabled={!data.emulator.integration.possible} className="tooltip not-aria-disabled:tooltip-primary" data-tip={data.emulator.integration.possible ? "Has Integration" : "Can Integrate"}>
<div className="bg-primary in-aria-disabled:bg-base-200 text-primary-content rounded-full p-1.5"><WandSparkles className="size-5" /></div>
</div>}
{data.emulator.validSources.slice(0, 3).map(s =>
{

View file

@ -3,7 +3,7 @@ import { RPC_URL } from "@shared/constants";
import { useEffect, useRef, useState } from "react";
import { FocusContext, useFocusable } from "@noriginmedia/norigin-spatial-navigation";
import { Calendar, Folder, Gamepad2, Image, Info, TriangleAlert, Trophy } from "lucide-react";
import { HeaderUI } from "../../components/Header";
import { HeaderUI, StickyHeaderUI } from "../../components/Header";
import { AnimatedBackground } from "../../components/AnimatedBackground";
import { useQuery } from "@tanstack/react-query";
import Shortcuts from "../../components/Shortcuts";
@ -146,7 +146,6 @@ function RouteComponent ()
const [, setUpdate] = useState(0);
const { ref, focusKey, focusSelf } = useFocusable({ focusKey: "game-details", preferredChildFocusKey: "main-details", forceFocus: true });
const headerRef = useRef(null);
const sentinelRef = useRef(null);
const backgroundImage = data ? new URL(`${RPC_URL(__HOST__)}${data.path_cover}`) : undefined;
const { data: recommendedGames } = useQuery({ ...gamesRecommendedBasedOnGameQuery(data?.id.source ?? source, data?.id.id ?? id), enabled: !!data && recommendedGamesVisible });
@ -158,7 +157,6 @@ function RouteComponent ()
const { shortcuts } = useShortcutContext();
useStickyDataAttr(headerRef, sentinelRef, ref);
const recommendedEmulators = data?.emulators?.filter(e => e.validSources.some(em => em.exists));
const { ref: intersct } = useIntersectionObserver({
@ -176,10 +174,7 @@ function RouteComponent ()
}} >
<div className="z-10">
<FocusContext value={focusKey}>
<div ref={sentinelRef} className="h-0" />
<div ref={headerRef} className="sticky group top-0 bg-base-100/40 group p-2 z-15 transition-colors data-stuck:backdrop-blur-3xl">
<HeaderUI />
</div>
<StickyHeaderUI ref={headerRef} className="not-data-stuck:bg-base-200/40" />
<div className="flex flex-col h-[calc(100vh-12rem)] overflow-hidden bg-linear-to-t from-base-100 to-base-100/40">
<Details game={data} id={id} source={source} />
</div>

View file

@ -315,8 +315,8 @@ export default function ConsoleHomeUI ()
headerButtons.push({ id: "fullscreen", icon: <Maximize />, action: handleFullscreen });
headerButtons.push(
{ id: "search-header-button", icon: <Search /> },
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate() },
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => Router.navigate({ to: "/settings/accounts" }) }
{ id: "power-button", icon: <Power />, external: true, action: () => close.mutate(), className: "focusable-error!" },
{ id: "settings-header-button", icon: <Settings />, external: true, action: () => router.navigate({ to: "/settings/accounts" }) }
);
return (

View file

@ -1,7 +1,7 @@
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { Joystick } from 'lucide-react';
import { createFileRoute, ErrorComponentProps, useSearch } from '@tanstack/react-router';
import { Joystick, TriangleAlert } from 'lucide-react';
import { useContext, useEffect } from 'react';
import { FocusContext, getCurrentFocusKey, useFocusable } from '@noriginmedia/norigin-spatial-navigation';
import { StoreEmulatorCard } from '@/mainview/components/store/StoreEmulatorCard';
@ -9,9 +9,11 @@ import { StoreContext } from '@/mainview/scripts/contexts';
import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import { useQuery } from '@tanstack/react-query';
import { storeEmulatorsQuery } from '@queries/store';
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
export const Route = createFileRoute('/store/tab/emulators')({
component: RouteComponent,
errorComponent: InvalidStoreError
});
function RouteComponent ()
@ -22,7 +24,7 @@ function RouteComponent ()
preferredChildFocusKey: focus
});
const storeContext = useContext(StoreContext);
const { data: emulators } = useQuery(storeEmulatorsQuery);
const { data: emulators } = useQuery({ ...storeEmulatorsQuery, retry: false, throwOnError: true });
useEffect(() =>
{

View file

@ -8,9 +8,11 @@ import { GetFocusedElement } from '@/mainview/scripts/spatialNavigation';
import LoadMoreButton from '@/mainview/components/LoadMoreButton';
import { storeGamesInfiniteQuery } from '@queries/store';
import { StoreContext } from '@/mainview/scripts/contexts';
import InvalidStoreError from '@/mainview/components/store/InvalidStoreError';
export const Route = createFileRoute('/store/tab/games')({
component: RouteComponent
component: RouteComponent,
errorComponent: InvalidStoreError
});
function RouteComponent ()

View file

@ -6,7 +6,7 @@ export const storeEmulatorsQuery = queryOptions({
queryKey: ['store-emulators'], queryFn: async () =>
{
const { data, error } = await storeApi.api.store.emulators.get();
if (error) throw error;
if (error) throw new Error(JSON.stringify(error.value));
return data;
}
});

View file

@ -107,7 +107,7 @@ SpatialNavigation.setCurrentFocusedKey = (newFocusKey, focusDetails) =>
node: GetFocusedElement(newFocusKey)
};
setCurrentFocusedKey(newFocusKey, focusDetails);
window.dispatchEvent(new CustomEvent<FocusEventDetails>('focuschanged', {
(GetFocusedElement(newFocusKey) ?? window).dispatchEvent(new CustomEvent<FocusEventDetails>('focuschanged', {
bubbles: true,
detail: details
}));