File
Blob: worker/src/client/exhibit/audio/audio-panel.tsx
| 1 | import { ActionLabel } from '@/client/ui/action-label.tsx'; |
| 2 | import { TrackMetadata } from './track-metadata.tsx'; |
| 3 | import type { Track } from '@/shared/contracts/track.ts'; |
| 4 | import type { ConnectionPhase } from '@/client/radio/session-state.ts'; |
| 5 | import { formatTime } from '@/client/ui/format.ts'; |
| 6 | import { Icon } from '@/client/ui/icons.tsx'; |
| 7 | import { BranchPanel } from '@/client/exhibit/branch-panel.tsx'; |
| 8 | import type { ExplanationTrigger } from '@/client/exhibit/diagram/diagram-model.ts'; |
| 9 | |
| 10 | const connectionLabels = ['Start listening', 'Cancel connection', 'Disconnect'] as const; |
| 11 | const soundLabels = ['Mute', 'Unmute', 'Enable sound'] as const; |
| 12 | |
| 13 | type Props = ExplanationTrigger & { |
| 14 | phase: ConnectionPhase; |
| 15 | available: boolean; |
| 16 | track?: Track; |
| 17 | position: number; |
| 18 | duration: number; |
| 19 | kbps: number; |
| 20 | live: boolean; |
| 21 | muted: boolean; |
| 22 | volume: number; |
| 23 | audioBlocked: boolean; |
| 24 | onConnect: () => void; |
| 25 | onDisconnect: () => void; |
| 26 | onToggleAudio: () => void; |
| 27 | onVolumeChange: (volume: number) => void; |
| 28 | }; |
| 29 | |
| 30 | export function AudioPanel({ |
| 31 | phase, |
| 32 | available, |
| 33 | track, |
| 34 | position, |
| 35 | duration, |
| 36 | kbps, |
| 37 | live, |
| 38 | muted, |
| 39 | volume, |
| 40 | audioBlocked, |
| 41 | onConnect, |
| 42 | onDisconnect, |
| 43 | onToggleAudio, |
| 44 | onVolumeChange, |
| 45 | ...explanation |
| 46 | }: Props) { |
| 47 | const connected = phase === 'connected', |
| 48 | connecting = phase === 'connecting'; |
| 49 | const soundLabel = audioBlocked ? 'Enable sound' : muted ? 'Unmute' : 'Mute'; |
| 50 | return ( |
| 51 | <BranchPanel |
| 52 | branch="audio" |
| 53 | label="Audio stream" |
| 54 | title="Listen in." |
| 55 | caption="OPUS AUDIO · 48 kHz STEREO" |
| 56 | live={live} |
| 57 | {...explanation} |
| 58 | > |
| 59 | <div className="flex min-w-0 items-start justify-between gap-3"> |
| 60 | <TrackMetadata track={track} /> |
| 61 | <span className="w-[10ch] shrink-0 pt-1 text-right font-mono text-[9px] text-muted"> |
| 62 | {live ? `${Math.round(kbps)} kb/s` : 'FROM FLASH'} |
| 63 | </span> |
| 64 | </div> |
| 65 | <div className="my-3 flex items-center gap-3 [&>div]:h-[3px] [&>div]:flex-1 [&>div]:overflow-hidden [&>div]:rounded [&>div]:bg-core [&>div>span]:block [&>div>span]:size-full [&>div>span]:origin-left [&>div>span]:bg-audio [&>span]:font-mono [&>span]:text-[10px] [&>span>span]:text-muted"> |
| 66 | <div |
| 67 | role="progressbar" |
| 68 | aria-label="Track position" |
| 69 | aria-valuemin={0} |
| 70 | aria-valuemax={duration || 1} |
| 71 | aria-valuenow={position} |
| 72 | > |
| 73 | <span |
| 74 | style={{ transform: `scaleX(${duration ? Math.min(1, position / duration) : 0})` }} |
| 75 | /> |
| 76 | </div> |
| 77 | <span> |
| 78 | {formatTime(position)} <span>/ {duration ? formatTime(duration) : '–:––'}</span> |
| 79 | </span> |
| 80 | </div> |
| 81 | <div className="@container"> |
| 82 | <div className="grid justify-items-start gap-2 @min-[24rem]:grid-cols-[max-content_minmax(0,1fr)] @min-[24rem]:items-center"> |
| 83 | <button |
| 84 | id="connect" |
| 85 | 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 border-ink bg-ink text-paper enabled:hover:bg-ink-hover" |
| 86 | disabled={!available} |
| 87 | onClick={() => { |
| 88 | if (phase !== 'idle') void onDisconnect(); |
| 89 | else void onConnect(); |
| 90 | }} |
| 91 | > |
| 92 | <Icon name={connected ? 'pause' : 'play'} /> |
| 93 | <ActionLabel |
| 94 | value={ |
| 95 | connecting ? 'Cancel connection' : connected ? 'Disconnect' : 'Start listening' |
| 96 | } |
| 97 | alternatives={connectionLabels} |
| 98 | /> |
| 99 | </button> |
| 100 | <div className="flex w-full min-w-0 items-center gap-2"> |
| 101 | <button |
| 102 | className="inline-flex min-h-11 shrink-0 items-center justify-center gap-1.5 rounded px-2 text-xs enabled:hover:bg-core" |
| 103 | disabled={!connected} |
| 104 | aria-label={soundLabel} |
| 105 | onClick={() => { |
| 106 | void onToggleAudio(); |
| 107 | }} |
| 108 | > |
| 109 | <Icon name={muted ? 'muted' : 'sound'} /> |
| 110 | <ActionLabel value={soundLabel} alternatives={soundLabels} /> |
| 111 | </button> |
| 112 | <label className="flex min-w-12 max-w-[105px] flex-1 flex-col [&_input]:h-11 [&_input]:w-full [&_input]:accent-ink"> |
| 113 | <span className="text-[9px] text-muted">Your volume</span> |
| 114 | <input |
| 115 | type="range" |
| 116 | min="0" |
| 117 | max="100" |
| 118 | value={Math.round(volume * 100)} |
| 119 | onChange={(event) => onVolumeChange(Number(event.target.value) / 100)} |
| 120 | aria-label="Listening volume" |
| 121 | /> |
| 122 | </label> |
| 123 | </div> |
| 124 | </div> |
| 125 | </div> |
| 126 | </BranchPanel> |
| 127 | ); |
| 128 | } |