File
Blob: worker/src/client/exhibit/controls/controls-panel.tsx
| 1 | import type { Command, RGB, Telemetry } from '@/shared/contracts/robot.ts'; |
| 2 | import type { AccessState, CommandAck } from '@/client/radio/session-state.ts'; |
| 3 | import { ActionLabel } from '@/client/ui/action-label.tsx'; |
| 4 | import { Icon } from '@/client/ui/icons.tsx'; |
| 5 | import { BranchPanel } from '@/client/exhibit/branch-panel.tsx'; |
| 6 | import type { ExplanationTrigger } from '@/client/exhibit/diagram/diagram-model.ts'; |
| 7 | import { LedControls } from './led-controls.tsx'; |
| 8 | import { TelemetryReadout } from './telemetry-readout.tsx'; |
| 9 | |
| 10 | type 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 | |
| 26 | export 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 | } |