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>
This commit is contained in:
170
src/pages/Dashboard.tsx
Normal file
170
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
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} · 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user