Skip to content
File

Blob: worker/src/client/exhibit/controls/controls-panel.tsx

typescript150 lines
1import type { Command, RGB, Telemetry } from '@/shared/contracts/robot.ts';
2import type { AccessState, CommandAck } from '@/client/radio/session-state.ts';
3import { ActionLabel } from '@/client/ui/action-label.tsx';
4import { Icon } from '@/client/ui/icons.tsx';
5import { BranchPanel } from '@/client/exhibit/branch-panel.tsx';
6import type { ExplanationTrigger } from '@/client/exhibit/diagram/diagram-model.ts';
7import { LedControls } from './led-controls.tsx';
8import { TelemetryReadout } from './telemetry-readout.tsx';
9 
10type Props = ExplanationTrigger & {
11 telemetry?: Telemetry;
12 trackCount: number;
13 paused: boolean;
14 led?: RGB;
15 auth: AccessState;
16 connected: boolean;
17 held: boolean;
18 occupied: boolean;
19 busy: boolean;
20 ack: CommandAck;
21 live: boolean;
22 onToggleControl: () => void;
23 onSend: (command: Command) => void;
24};
25 
26export function ControlsPanel({
27 telemetry,
28 trackCount,
29 paused,
30 led,
31 auth,
32 connected,
33 held,
34 occupied,
35 busy,
36 ack,
37 live,
38 onToggleControl,
39 onSend,
40 ...explanation
41}: Props) {
42 const pending = ack.state === 'pending';
43 const canSend = held && !pending;
44 const canToggle = connected && !busy && !occupied;
45 const send = (command: Command) => {
46 if (canSend) onSend(command);
47 };
48 return (
49 <BranchPanel
50 branch="robot"
51 label="Telemetry and board controls"
52 title="Telemetry & controls"
53 caption="RELIABLE DATA CHANNEL"
54 live={live}
55 {...explanation}
56 >
57 <TelemetryReadout telemetry={telemetry} />
58 <div className="mt-2.5 flex min-h-[3lh] items-center justify-between gap-2 text-[11px] leading-4.5 [&>span]:max-w-[18ch] [&>span]:text-[11px] [&>span]:text-muted">
59 <span>
60 {auth === 'checking'
61 ? 'Checking access…'
62 : auth === 'required'
63 ? 'Unlock access to use the controls.'
64 : !connected
65 ? 'Start listening to use the controls.'
66 : held
67 ? 'You have control'
68 : occupied
69 ? 'Another listener has control'
70 : 'Controls available'}
71 </span>
72 <button
73 id="claim"
74 className="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border border-line px-3 py-2 text-xs font-medium whitespace-nowrap px-2 text-[11px] enabled:hover:bg-core [&_svg]:size-3"
75 disabled={!connected || occupied}
76 aria-disabled={!canToggle}
77 aria-busy={busy}
78 onClick={() => {
79 if (canToggle) void onToggleControl();
80 }}
81 >
82 <ActionLabel
83 value={busy ? 'Updating…' : held ? 'Release control' : 'Take control'}
84 alternatives={['Updating…', 'Release control', 'Take control']}
85 />
86 <Icon name="arrow" />
87 </button>
88 </div>
89 <p className="mt-2 text-[11px] leading-relaxed text-muted">
90 LED and playback changes affect everyone.
91 </p>
92 <LedControls
93 led={led}
94 disabled={!held}
95 pending={pending}
96 onChange={(rgb) => send({ led: rgb })}
97 />
98 <div className="mt-2 flex flex-wrap items-center gap-x-3 [&>span]:mr-auto [&>span]:max-w-[12ch] [&>span]:font-mono [&>span]:text-[8px] [&>span]:tracking-wide [&>span]:text-muted">
99 <span>PLAYBACK</span>
100 <button
101 id="pause"
102 className="inline-flex min-h-10 items-center gap-1.5 text-xs underline-offset-4 enabled:hover:underline [&_svg]:size-3"
103 disabled={!held}
104 aria-disabled={!canSend}
105 onClick={() => send({ action: paused ? 'play' : 'pause' })}
106 >
107 <Icon name={paused ? 'play' : 'pause'} />
108 <ActionLabel
109 value={paused ? 'Resume track' : 'Pause track'}
110 alternatives={['Resume track', 'Pause track']}
111 />
112 </button>
113 <button
114 id="restart"
115 className="inline-flex min-h-10 items-center gap-1.5 text-xs underline-offset-4 enabled:hover:underline [&_svg]:size-3"
116 disabled={!held}
117 aria-disabled={!canSend}
118 onClick={() => send({ action: 'restart' })}
119 >
120 <Icon name="restart" />
121 Restart
122 </button>
123 <button
124 id="next-track"
125 className="inline-flex min-h-10 items-center gap-1.5 text-xs underline-offset-4 enabled:hover:underline [&_svg]:size-3"
126 disabled={!held || trackCount < 2}
127 aria-disabled={!canSend || trackCount < 2}
128 onClick={() => {
129 if (trackCount > 1) send({ action: 'next' });
130 }}
131 >
132 <Icon name="arrow" />
133 Next track
134 </button>
135 </div>
136 <div className="@container">
137 <p
138 className={`min-h-[2lh] text-[11px] leading-4.5 wrap-anywhere text-muted @min-[15rem]:min-h-[1lh] ${ack.state === 'confirmed' ? 'text-spectrum' : ack.state === 'pending' ? 'text-robot' : ack.state === 'error' ? 'text-error' : ''}`}
139 role="status"
140 aria-live="polite"
141 data-testid="ack"
142 >
143 {ack.state === 'confirmed' ? '✓ ' : ack.state === 'pending' ? '↖ ' : ''}
144 {ack.text}
145 </p>
146 </div>
147 </BranchPanel>
148 );
149}