Skip to content
File

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

typescript129 lines
1import { ActionLabel } from '@/client/ui/action-label.tsx';
2import { TrackMetadata } from './track-metadata.tsx';
3import type { Track } from '@/shared/contracts/track.ts';
4import type { ConnectionPhase } from '@/client/radio/session-state.ts';
5import { formatTime } from '@/client/ui/format.ts';
6import { Icon } from '@/client/ui/icons.tsx';
7import { BranchPanel } from '@/client/exhibit/branch-panel.tsx';
8import type { ExplanationTrigger } from '@/client/exhibit/diagram/diagram-model.ts';
9 
10const connectionLabels = ['Start listening', 'Cancel connection', 'Disconnect'] as const;
11const soundLabels = ['Mute', 'Unmute', 'Enable sound'] as const;
12 
13type 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 
30export 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}