Files
8bitdo-Command-Center/src/pages/Dashboard.tsx
oceans2alaska 6507f5f8b3 Initial release of 8BitDo Control Center
Provide a Linux desktop controller configuration app with evdev input, uinput remapping, profiles, calibration, and reconnect recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 12:18:10 -07:00

171 lines
7.5 KiB
TypeScript

import { BatteryMedium, Gamepad2, Power, Usb } from "lucide-react";
import { Badge } from "@/components/common/Badge";
import { Panel } from "@/components/common/Panel";
import { StatusDot } from "@/components/common/StatusDot";
import { PageShell } from "@/components/layout/PageShell";
import { Select } from "@/components/inputs/Select";
import { Toggle } from "@/components/inputs/Toggle";
import { StickPreview } from "@/components/visualizations/StickPreview";
import { TriggerPreview } from "@/components/visualizations/TriggerPreview";
import { useControllerStore } from "@/stores/controllerStore";
import { useProfileStore } from "@/stores/profileStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { processStick, processTrigger } from "@/utils/deadzoneMath";
export function Dashboard() {
const controllers = useControllerStore((s) => s.controllers);
const activeController = useControllerStore((s) => s.activeController());
const liveState = useControllerStore((s) => s.liveState);
const pollRateHz = useControllerStore((s) => s.pollRateHz);
const connectionStatus = useControllerStore((s) => s.connectionStatus);
const connectionMessage = useControllerStore((s) => s.connectionMessage);
const draftProfile = useProfileStore((s) => s.draftProfile);
const summaries = useProfileStore((s) => s.summaries);
const selectProfile = useProfileStore((s) => s.selectProfile);
const remappingEnabled = useSettingsStore((s) => s.remappingEnabled);
const setRemappingEnabled = useSettingsStore((s) => s.setRemappingEnabled);
const setControllerRemapping = useControllerStore((s) => s.setRemappingEnabled);
const axes = liveState?.axes ?? {};
const leftStick = draftProfile
? processStick({ x: axes.left_stick_x ?? 0, y: axes.left_stick_y ?? 0 }, draftProfile.leftStick)
: null;
const rightStick = draftProfile
? processStick({ x: axes.right_stick_x ?? 0, y: axes.right_stick_y ?? 0 }, draftProfile.rightStick)
: null;
const leftTrigger = draftProfile ? processTrigger(axes.left_trigger ?? 0, draftProfile.leftTrigger) : null;
const rightTrigger = draftProfile ? processTrigger(axes.right_trigger ?? 0, draftProfile.rightTrigger) : null;
return (
<PageShell title="Dashboard" description="At-a-glance status for the connected controller and active profile.">
<div className="grid grid-cols-3 gap-5">
<Panel title="Connected Controller" className="col-span-2">
{activeController ? (
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-accent/15 text-accent-strong">
<Gamepad2 size={26} />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-fg">{activeController.name}</p>
<p className="mt-0.5 text-xs text-fg-muted">
{activeController.devicePath} &middot; VID {activeController.vendorId.toString(16).padStart(4, "0")}{" "}
PID {activeController.productId.toString(16).padStart(4, "0")}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge tone="success">
<StatusDot tone="success" pulse />
Connected
</Badge>
<Badge tone="neutral">
<Usb size={12} /> {activeController.mode.toUpperCase()}
</Badge>
{activeController.batteryPercent !== null && (
<Badge tone="neutral">
<BatteryMedium size={12} /> {activeController.batteryPercent}%
</Badge>
)}
<Badge tone={activeController.virtualControllerActive ? "accent" : "warning"}>
<Power size={12} />
Virtual controller {activeController.virtualControllerActive ? "active" : "inactive"}
</Badge>
{activeController.isSimulated && <Badge tone="warning">Simulated device</Badge>}
</div>
</div>
</div>
) : (
<p className="text-sm text-fg-muted">
{connectionStatus === "reconnecting"
? connectionMessage ?? "Reconnecting to controller…"
: "No controller detected. Connect an 8BitDo Ultimate controller to get started."}
</p>
)}
</Panel>
<Panel title="Remapping">
<Toggle
checked={remappingEnabled}
onChange={(enabled) => {
setRemappingEnabled(enabled);
void setControllerRemapping(enabled);
}}
label="Enable remapping"
description="Route input through the virtual controller"
/>
<div className="mt-4 border-t border-border pt-4">
<span className="mb-1.5 block text-xs text-fg-muted">Poll rate</span>
<span className="font-mono text-sm text-fg">{pollRateHz ? `${pollRateHz} Hz` : "—"}</span>
</div>
</Panel>
<Panel title="Active Profile" className="col-span-3">
<div className="flex items-center gap-4">
<div className="w-64">
<Select
value={draftProfile?.id ?? ""}
options={summaries.map((s) => ({ value: s.id, label: s.name }))}
onChange={(id) => void selectProfile(id)}
/>
</div>
{draftProfile?.description && <p className="text-sm text-fg-muted">{draftProfile.description}</p>}
{controllers.length > 1 && (
<span className="ml-auto text-xs text-fg-subtle">{controllers.length} controllers detected</span>
)}
</div>
</Panel>
<Panel title="Left Stick">
{leftStick && draftProfile ? (
<StickPreview
raw={leftStick.raw}
processed={leftStick.processed}
innerDeadzone={draftProfile.leftStick.innerDeadzone}
outerDeadzone={draftProfile.leftStick.outerDeadzone}
/>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
<Panel title="Right Stick">
{rightStick && draftProfile ? (
<StickPreview
raw={rightStick.raw}
processed={rightStick.processed}
innerDeadzone={draftProfile.rightStick.innerDeadzone}
outerDeadzone={draftProfile.rightStick.outerDeadzone}
/>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
<Panel title="Triggers">
{leftTrigger && rightTrigger && draftProfile ? (
<div className="flex flex-col gap-4">
<TriggerPreview
label="Left Trigger"
raw={leftTrigger.raw}
processed={leftTrigger.processed}
deadzone={draftProfile.leftTrigger.deadzone}
maxActivation={draftProfile.leftTrigger.maxActivation}
/>
<TriggerPreview
label="Right Trigger"
raw={rightTrigger.raw}
processed={rightTrigger.processed}
deadzone={draftProfile.rightTrigger.deadzone}
maxActivation={draftProfile.rightTrigger.maxActivation}
/>
</div>
) : (
<p className="text-sm text-fg-subtle">No data</p>
)}
</Panel>
</div>
</PageShell>
);
}