@bufinance/radio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +34 -0
- package/src/channels.ts +112 -0
- package/src/cn.ts +20 -0
- package/src/copy.ts +44 -0
- package/src/index.ts +7 -0
- package/src/radio-bar.tsx +749 -0
- package/src/use-channel-availability.ts +129 -0
- package/src/use-channel-discovery.ts +64 -0
- package/src/use-yt-player.ts +113 -0
- package/tsconfig.json +5 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
12
|
+
import { useHotkeys } from "react-hotkeys-hook";
|
|
13
|
+
import {
|
|
14
|
+
ChevronLeft,
|
|
15
|
+
ChevronRight,
|
|
16
|
+
Pause,
|
|
17
|
+
Play,
|
|
18
|
+
Radio,
|
|
19
|
+
X,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { cn } from "./cn";
|
|
22
|
+
import { CHANNELS, type Channel } from "./channels";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_RADIO_COPY,
|
|
25
|
+
interp,
|
|
26
|
+
type RadioCopy,
|
|
27
|
+
} from "./copy";
|
|
28
|
+
import { useChannelAvailability } from "./use-channel-availability";
|
|
29
|
+
import {
|
|
30
|
+
useChannelDiscovery,
|
|
31
|
+
type ResolvedChannel,
|
|
32
|
+
} from "./use-channel-discovery";
|
|
33
|
+
import { useYouTubePlayer } from "./use-yt-player";
|
|
34
|
+
|
|
35
|
+
type View = "idle" | "expanded";
|
|
36
|
+
|
|
37
|
+
export type RadioBarProps = {
|
|
38
|
+
/** Master audio toggle — when false the radio pauses (mirrors the app header mute). */
|
|
39
|
+
audioEnabled: boolean;
|
|
40
|
+
/** Setter for the master audio toggle; pressing play flips it on. */
|
|
41
|
+
setAudioEnabled: (v: boolean) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Endpoint that resolves query-based channels. Expected to return
|
|
44
|
+
* `{ resolved: Record<channelId, videoId> }`. Omit it to run videoId-only.
|
|
45
|
+
*/
|
|
46
|
+
discoverUrl?: string;
|
|
47
|
+
/** Localized strings. Shallow-merged over the English `DEFAULT_RADIO_COPY`. */
|
|
48
|
+
copy?: Partial<RadioCopy>;
|
|
49
|
+
/** Channel list. Defaults to the bundled `CHANNELS`. */
|
|
50
|
+
channels?: Channel[];
|
|
51
|
+
/** App-specific content rendered in the footer line (e.g. TVL pill, community links). */
|
|
52
|
+
extras?: React.ReactNode;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Hardcoded morph dimensions — must match the inner content layout.
|
|
56
|
+
const SIZES = {
|
|
57
|
+
idle: { width: 200, height: 34, radius: 999 },
|
|
58
|
+
expanded: { width: 420, height: 432, radius: 28 },
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
export function RadioBar({
|
|
62
|
+
audioEnabled,
|
|
63
|
+
setAudioEnabled,
|
|
64
|
+
discoverUrl,
|
|
65
|
+
copy,
|
|
66
|
+
channels = CHANNELS,
|
|
67
|
+
extras,
|
|
68
|
+
}: RadioBarProps) {
|
|
69
|
+
const t: RadioCopy = useMemo(
|
|
70
|
+
() => ({ ...DEFAULT_RADIO_COPY, ...copy }),
|
|
71
|
+
[copy],
|
|
72
|
+
);
|
|
73
|
+
const [view, setView] = useState<View>("idle");
|
|
74
|
+
const [channelIdx, setChannelIdx] = useState(0);
|
|
75
|
+
const [playing, setPlaying] = useState(false);
|
|
76
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
const videoSlotRef = useRef<HTMLDivElement | null>(null);
|
|
78
|
+
const [slotBounds, setSlotBounds] = useState<{
|
|
79
|
+
top: number;
|
|
80
|
+
left: number;
|
|
81
|
+
width: number;
|
|
82
|
+
height: number;
|
|
83
|
+
} | null>(null);
|
|
84
|
+
|
|
85
|
+
const { unavailable, markUnavailable } = useChannelAvailability(channels);
|
|
86
|
+
const { channels: discoveredChannels } = useChannelDiscovery(
|
|
87
|
+
channels,
|
|
88
|
+
discoverUrl,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// A channel is "playable" when (a) we have an effective videoId for it
|
|
92
|
+
// (verified static or resolved from search) AND (b) it hasn't been
|
|
93
|
+
// marked dead by oEmbed/onError. This is the canonical visible list.
|
|
94
|
+
const playableChannels = useMemo(
|
|
95
|
+
() =>
|
|
96
|
+
discoveredChannels.filter(
|
|
97
|
+
(c) => c.effectiveVideoId && !unavailable.has(c.id),
|
|
98
|
+
),
|
|
99
|
+
[discoveredChannels, unavailable],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Find next/prev playable channel index. Hops over dead ones.
|
|
103
|
+
const findNextPlayable = useCallback(
|
|
104
|
+
(fromIdx: number, direction: 1 | -1) => {
|
|
105
|
+
const len = playableChannels.length;
|
|
106
|
+
if (len === 0) return fromIdx;
|
|
107
|
+
const currentId = discoveredChannels[fromIdx]?.id;
|
|
108
|
+
const currentInPlayable = playableChannels.findIndex(
|
|
109
|
+
(c) => c.id === currentId,
|
|
110
|
+
);
|
|
111
|
+
const start = currentInPlayable >= 0 ? currentInPlayable : 0;
|
|
112
|
+
const next = ((start + direction + len) % len + len) % len;
|
|
113
|
+
const nextId = playableChannels[next].id;
|
|
114
|
+
return discoveredChannels.findIndex((c) => c.id === nextId);
|
|
115
|
+
},
|
|
116
|
+
[discoveredChannels, playableChannels],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// If the current channel just became unplayable, hop to the next live one.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
const current = discoveredChannels[channelIdx];
|
|
122
|
+
if (!current) return;
|
|
123
|
+
const stillPlayable =
|
|
124
|
+
current.effectiveVideoId && !unavailable.has(current.id);
|
|
125
|
+
if (!stillPlayable && playableChannels.length > 0) {
|
|
126
|
+
const nextIdx = findNextPlayable(channelIdx, 1);
|
|
127
|
+
if (nextIdx >= 0 && nextIdx !== channelIdx) setChannelIdx(nextIdx);
|
|
128
|
+
}
|
|
129
|
+
}, [
|
|
130
|
+
discoveredChannels,
|
|
131
|
+
channelIdx,
|
|
132
|
+
unavailable,
|
|
133
|
+
playableChannels,
|
|
134
|
+
findNextPlayable,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const channel: ResolvedChannel | undefined = discoveredChannels[channelIdx];
|
|
138
|
+
const videoIdToPlay = channel?.effectiveVideoId ?? null;
|
|
139
|
+
|
|
140
|
+
const { mountRef, player } = useYouTubePlayer({
|
|
141
|
+
videoId: videoIdToPlay ?? "",
|
|
142
|
+
onStateChange: (state) => {
|
|
143
|
+
if (state === 1) setPlaying(true);
|
|
144
|
+
else if (state === 2 || state === 0) setPlaying(false);
|
|
145
|
+
},
|
|
146
|
+
onError: (code) => {
|
|
147
|
+
// 2 = invalid id, 100 = not found, 101/150 = embedding disallowed
|
|
148
|
+
if (code === 2 || code === 100 || code === 101 || code === 150) {
|
|
149
|
+
if (channel) {
|
|
150
|
+
markUnavailable(channel.id);
|
|
151
|
+
const nextIdx = findNextPlayable(channelIdx, 1);
|
|
152
|
+
if (nextIdx >= 0 && nextIdx !== channelIdx) setChannelIdx(nextIdx);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Master mute: when the header toggle turns audio off, pause the radio.
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!audioEnabled && player && playing) player.pauseVideo();
|
|
161
|
+
}, [audioEnabled, player, playing]);
|
|
162
|
+
|
|
163
|
+
const togglePlay = useCallback(() => {
|
|
164
|
+
if (!player) return;
|
|
165
|
+
if (playing) {
|
|
166
|
+
player.pauseVideo();
|
|
167
|
+
} else {
|
|
168
|
+
// Pressing play is an explicit "I want sound" — unmute the master toggle.
|
|
169
|
+
if (!audioEnabled) setAudioEnabled(true);
|
|
170
|
+
player.playVideo();
|
|
171
|
+
}
|
|
172
|
+
}, [player, playing, audioEnabled, setAudioEnabled]);
|
|
173
|
+
|
|
174
|
+
const goToChannelId = useCallback(
|
|
175
|
+
(id: string) => {
|
|
176
|
+
const idx = discoveredChannels.findIndex((c) => c.id === id);
|
|
177
|
+
if (idx < 0) return;
|
|
178
|
+
setChannelIdx(idx);
|
|
179
|
+
requestAnimationFrame(() => {
|
|
180
|
+
const node = scrollerRef.current?.querySelector<HTMLElement>(
|
|
181
|
+
`[data-channel-id="${id}"]`,
|
|
182
|
+
);
|
|
183
|
+
node?.scrollIntoView({
|
|
184
|
+
behavior: "smooth",
|
|
185
|
+
inline: "center",
|
|
186
|
+
block: "nearest",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
[discoveredChannels],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const prev = useCallback(() => {
|
|
194
|
+
const idx = findNextPlayable(channelIdx, -1);
|
|
195
|
+
const target = discoveredChannels[idx];
|
|
196
|
+
if (target) goToChannelId(target.id);
|
|
197
|
+
}, [channelIdx, findNextPlayable, goToChannelId, discoveredChannels]);
|
|
198
|
+
|
|
199
|
+
const next = useCallback(() => {
|
|
200
|
+
const idx = findNextPlayable(channelIdx, 1);
|
|
201
|
+
const target = discoveredChannels[idx];
|
|
202
|
+
if (target) goToChannelId(target.id);
|
|
203
|
+
}, [channelIdx, findNextPlayable, goToChannelId, discoveredChannels]);
|
|
204
|
+
|
|
205
|
+
// ⌘B / Ctrl+B — toggle the radio dynamic island (BUFX Radio hotkey)
|
|
206
|
+
useHotkeys(
|
|
207
|
+
"mod+b",
|
|
208
|
+
() => setView((v) => (v === "idle" ? "expanded" : "idle")),
|
|
209
|
+
{ preventDefault: true, enableOnFormTags: false },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// Esc — collapse the expanded radio
|
|
213
|
+
useHotkeys(
|
|
214
|
+
"esc",
|
|
215
|
+
() => {
|
|
216
|
+
if (view === "expanded") setView("idle");
|
|
217
|
+
},
|
|
218
|
+
{ enabled: view === "expanded" },
|
|
219
|
+
[view],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Track the video slot's viewport coords so the persistent iframe can
|
|
223
|
+
// overlay it visually. Iframe stays mounted permanently so audio keeps
|
|
224
|
+
// playing across morph state changes.
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
if (view !== "expanded") {
|
|
227
|
+
setSlotBounds(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const update = () => {
|
|
231
|
+
if (!videoSlotRef.current) return;
|
|
232
|
+
const rect = videoSlotRef.current.getBoundingClientRect();
|
|
233
|
+
setSlotBounds({
|
|
234
|
+
top: rect.top,
|
|
235
|
+
left: rect.left,
|
|
236
|
+
width: rect.width,
|
|
237
|
+
height: rect.height,
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
// Poll during morph animation; settle after spring completes.
|
|
241
|
+
const start = performance.now();
|
|
242
|
+
let raf = 0;
|
|
243
|
+
const tick = () => {
|
|
244
|
+
update();
|
|
245
|
+
if (performance.now() - start < 700) {
|
|
246
|
+
raf = requestAnimationFrame(tick);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
raf = requestAnimationFrame(tick);
|
|
250
|
+
const onResize = () => update();
|
|
251
|
+
window.addEventListener("resize", onResize);
|
|
252
|
+
window.addEventListener("scroll", onResize, true);
|
|
253
|
+
return () => {
|
|
254
|
+
cancelAnimationFrame(raf);
|
|
255
|
+
window.removeEventListener("resize", onResize);
|
|
256
|
+
window.removeEventListener("scroll", onResize, true);
|
|
257
|
+
};
|
|
258
|
+
}, [view]);
|
|
259
|
+
|
|
260
|
+
const currentSize = SIZES[view];
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<>
|
|
264
|
+
{/* Footer line — extras on the left, transport + island on the far right */}
|
|
265
|
+
{/* 3-column grid: equal minmax(0,1fr) side columns make the center
|
|
266
|
+
column land on the true viewport center, independent of the extras
|
|
267
|
+
/ radio-island widths (which a flex layout couldn't do). */}
|
|
268
|
+
<div
|
|
269
|
+
className="relative z-10 grid items-center w-full mt-6 pb-5 gap-1.5 px-2"
|
|
270
|
+
style={{ gridTemplateColumns: "minmax(0,1fr) auto minmax(0,1fr)" }}
|
|
271
|
+
>
|
|
272
|
+
{/* left: injected app extras (e.g. TVL pill) + hairline toward center */}
|
|
273
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
274
|
+
{extras}
|
|
275
|
+
<span className="h-px flex-1 bg-purpleDanis/70" />
|
|
276
|
+
</div>
|
|
277
|
+
{/* center: reserved for app-centered content (kept truly centered) */}
|
|
278
|
+
<div />
|
|
279
|
+
{/* right: hairline + transport controls + radio island */}
|
|
280
|
+
<div className="flex min-w-0 items-center justify-end gap-1.5">
|
|
281
|
+
<span className="h-px flex-1 bg-purpleDanis/70" />
|
|
282
|
+
|
|
283
|
+
<div className="flex items-center gap-0.5">
|
|
284
|
+
<IconButton onClick={prev} ariaLabel={t.previousChannel}>
|
|
285
|
+
<ChevronLeft className="h-4 w-4 text-purpleDanis dark:text-violetDanis" />
|
|
286
|
+
</IconButton>
|
|
287
|
+
<IconButton onClick={next} ariaLabel={t.nextChannel}>
|
|
288
|
+
<ChevronRight className="h-4 w-4 text-purpleDanis dark:text-violetDanis" />
|
|
289
|
+
</IconButton>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{/* Morph slot — reserves space for the idle pill so the bar layout is stable */}
|
|
293
|
+
<div
|
|
294
|
+
className="relative"
|
|
295
|
+
style={{ width: SIZES.idle.width, height: SIZES.idle.height }}
|
|
296
|
+
>
|
|
297
|
+
<motion.div
|
|
298
|
+
initial={false}
|
|
299
|
+
animate={{
|
|
300
|
+
width: currentSize.width,
|
|
301
|
+
height: currentSize.height,
|
|
302
|
+
borderRadius: currentSize.radius,
|
|
303
|
+
}}
|
|
304
|
+
transition={{ type: "spring", bounce: 0.32, duration: 0.5 }}
|
|
305
|
+
style={{ transformOrigin: "100% 100%" }}
|
|
306
|
+
className={cn(
|
|
307
|
+
"absolute right-0 bottom-0 overflow-hidden backdrop-blur-xl ring-1 z-50",
|
|
308
|
+
"bg-white/95 ring-purpleDanis/15 shadow-[0_14px_36px_-12px_rgba(105,84,207,0.4)]",
|
|
309
|
+
"dark:bg-black/95 dark:ring-white/10 dark:shadow-[0_18px_50px_-16px_rgba(105,84,207,0.65)]",
|
|
310
|
+
)}
|
|
311
|
+
>
|
|
312
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
313
|
+
{view === "idle" ? (
|
|
314
|
+
<motion.div
|
|
315
|
+
key="idle"
|
|
316
|
+
initial={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
|
|
317
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
318
|
+
exit={{ opacity: 0, scale: 0.92, filter: "blur(4px)" }}
|
|
319
|
+
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
|
|
320
|
+
className="h-full w-full"
|
|
321
|
+
>
|
|
322
|
+
<IdleView
|
|
323
|
+
channel={channel}
|
|
324
|
+
copy={t}
|
|
325
|
+
playing={playing}
|
|
326
|
+
onTogglePlay={togglePlay}
|
|
327
|
+
onExpand={() => setView("expanded")}
|
|
328
|
+
/>
|
|
329
|
+
</motion.div>
|
|
330
|
+
) : (
|
|
331
|
+
<motion.div
|
|
332
|
+
key="expanded"
|
|
333
|
+
initial={{ opacity: 0, scale: 0.94, filter: "blur(4px)" }}
|
|
334
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
335
|
+
exit={{ opacity: 0, scale: 0.94, filter: "blur(4px)" }}
|
|
336
|
+
transition={{
|
|
337
|
+
duration: 0.22,
|
|
338
|
+
ease: [0.22, 1, 0.36, 1],
|
|
339
|
+
delay: 0.08,
|
|
340
|
+
}}
|
|
341
|
+
className="h-full w-full"
|
|
342
|
+
>
|
|
343
|
+
<ExpandedView
|
|
344
|
+
channel={channel}
|
|
345
|
+
channels={playableChannels}
|
|
346
|
+
copy={t}
|
|
347
|
+
playing={playing}
|
|
348
|
+
videoSlotRef={videoSlotRef}
|
|
349
|
+
scrollerRef={scrollerRef}
|
|
350
|
+
onTogglePlay={togglePlay}
|
|
351
|
+
onChannelSelect={goToChannelId}
|
|
352
|
+
onClose={() => setView("idle")}
|
|
353
|
+
/>
|
|
354
|
+
</motion.div>
|
|
355
|
+
)}
|
|
356
|
+
</AnimatePresence>
|
|
357
|
+
</motion.div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Persistent YouTube iframe — fixed-positioned overlay that mirrors
|
|
363
|
+
the video slot inside the expanded card. Never unmounted, so audio
|
|
364
|
+
keeps playing when the morph collapses. */}
|
|
365
|
+
<div
|
|
366
|
+
className="fixed overflow-hidden bg-black transition-opacity duration-300 ease-out"
|
|
367
|
+
style={{
|
|
368
|
+
top: slotBounds?.top ?? -9999,
|
|
369
|
+
left: slotBounds?.left ?? -9999,
|
|
370
|
+
width: slotBounds?.width ?? 1,
|
|
371
|
+
height: slotBounds?.height ?? 1,
|
|
372
|
+
opacity: view === "expanded" && slotBounds ? 1 : 0,
|
|
373
|
+
pointerEvents: view === "expanded" && slotBounds ? "auto" : "none",
|
|
374
|
+
borderRadius: 16,
|
|
375
|
+
zIndex: 51,
|
|
376
|
+
}}
|
|
377
|
+
aria-hidden={view !== "expanded"}
|
|
378
|
+
>
|
|
379
|
+
<div ref={mountRef} className="w-full h-full" />
|
|
380
|
+
</div>
|
|
381
|
+
</>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function IdleView({
|
|
386
|
+
channel,
|
|
387
|
+
copy,
|
|
388
|
+
playing,
|
|
389
|
+
onTogglePlay,
|
|
390
|
+
onExpand,
|
|
391
|
+
}: {
|
|
392
|
+
channel: Channel | undefined;
|
|
393
|
+
copy: RadioCopy;
|
|
394
|
+
playing: boolean;
|
|
395
|
+
onTogglePlay: () => void;
|
|
396
|
+
onExpand: () => void;
|
|
397
|
+
}) {
|
|
398
|
+
return (
|
|
399
|
+
<div className="flex items-center gap-2 pl-1.5 pr-3 h-full">
|
|
400
|
+
<motion.button
|
|
401
|
+
type="button"
|
|
402
|
+
onClick={(e) => {
|
|
403
|
+
e.stopPropagation();
|
|
404
|
+
onTogglePlay();
|
|
405
|
+
}}
|
|
406
|
+
whileTap={{ scale: 0.88 }}
|
|
407
|
+
transition={{ type: "spring", stiffness: 700, damping: 22 }}
|
|
408
|
+
aria-label={playing ? copy.pause : copy.play}
|
|
409
|
+
className={cn(
|
|
410
|
+
"h-6 w-6 shrink-0 grid place-items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2",
|
|
411
|
+
"bg-purpleDanis text-white hover:bg-[#7864E0] focus-visible:ring-purpleDanis/50",
|
|
412
|
+
"dark:bg-white dark:text-purpleDanis dark:hover:bg-white dark:focus-visible:ring-white/60",
|
|
413
|
+
)}
|
|
414
|
+
>
|
|
415
|
+
<AnimatePresence initial={false} mode="wait">
|
|
416
|
+
{playing ? (
|
|
417
|
+
<motion.span
|
|
418
|
+
key="pause"
|
|
419
|
+
initial={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
|
|
420
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
421
|
+
exit={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
|
|
422
|
+
transition={{ duration: 0.1 }}
|
|
423
|
+
>
|
|
424
|
+
<Pause className="h-2.5 w-2.5 fill-current" />
|
|
425
|
+
</motion.span>
|
|
426
|
+
) : (
|
|
427
|
+
<motion.span
|
|
428
|
+
key="play"
|
|
429
|
+
initial={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
|
|
430
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
431
|
+
exit={{ opacity: 0, scale: 0.5, filter: "blur(2px)" }}
|
|
432
|
+
transition={{ duration: 0.1 }}
|
|
433
|
+
>
|
|
434
|
+
<Play className="h-2.5 w-2.5 fill-current translate-x-[0.5px]" />
|
|
435
|
+
</motion.span>
|
|
436
|
+
)}
|
|
437
|
+
</AnimatePresence>
|
|
438
|
+
</motion.button>
|
|
439
|
+
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
onClick={onExpand}
|
|
443
|
+
className="flex items-center gap-1.5 min-w-0 focus-visible:outline-none rounded-full flex-1 text-left"
|
|
444
|
+
aria-label={copy.openRadio}
|
|
445
|
+
>
|
|
446
|
+
<div
|
|
447
|
+
className="overflow-hidden flex-1 min-w-0 [mask-image:linear-gradient(to_right,transparent,black_10%,black_88%,transparent)]"
|
|
448
|
+
aria-label={interp(copy.nowPlaying, { name: channel?.name ?? "" })}
|
|
449
|
+
>
|
|
450
|
+
<div
|
|
451
|
+
className="inline-flex whitespace-nowrap will-change-transform animate-marquee"
|
|
452
|
+
style={{
|
|
453
|
+
animationDuration: "16s",
|
|
454
|
+
animationPlayState: playing ? "running" : "paused",
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
<MarqueeRow channel={channel} />
|
|
458
|
+
<MarqueeRow channel={channel} />
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
<motion.span
|
|
462
|
+
className="ml-auto shrink-0 text-purpleDanis/50 dark:text-white/55"
|
|
463
|
+
animate={{ opacity: playing ? [0.4, 1, 0.4] : 0.4 }}
|
|
464
|
+
transition={
|
|
465
|
+
playing
|
|
466
|
+
? { duration: 1.6, repeat: Infinity, ease: "easeInOut" }
|
|
467
|
+
: { duration: 0.2 }
|
|
468
|
+
}
|
|
469
|
+
aria-hidden
|
|
470
|
+
>
|
|
471
|
+
<Radio className="h-3 w-3" />
|
|
472
|
+
</motion.span>
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function ExpandedView({
|
|
479
|
+
channel,
|
|
480
|
+
channels,
|
|
481
|
+
copy,
|
|
482
|
+
playing,
|
|
483
|
+
videoSlotRef,
|
|
484
|
+
scrollerRef,
|
|
485
|
+
onTogglePlay,
|
|
486
|
+
onChannelSelect,
|
|
487
|
+
onClose,
|
|
488
|
+
}: {
|
|
489
|
+
channel: Channel | undefined;
|
|
490
|
+
channels: Channel[];
|
|
491
|
+
copy: RadioCopy;
|
|
492
|
+
playing: boolean;
|
|
493
|
+
videoSlotRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
494
|
+
scrollerRef: React.RefObject<HTMLDivElement | null>;
|
|
495
|
+
onTogglePlay: () => void;
|
|
496
|
+
onChannelSelect: (id: string) => void;
|
|
497
|
+
onClose: () => void;
|
|
498
|
+
}) {
|
|
499
|
+
return (
|
|
500
|
+
<div className="relative" style={{ width: SIZES.expanded.width }}>
|
|
501
|
+
<div
|
|
502
|
+
className="absolute inset-x-0 top-0 h-32 pointer-events-none opacity-50 dark:opacity-70"
|
|
503
|
+
style={{
|
|
504
|
+
background: `radial-gradient(60% 100% at 50% 0%, ${channel?.accent ?? "#6954CF"}55 0%, transparent 70%)`,
|
|
505
|
+
}}
|
|
506
|
+
aria-hidden
|
|
507
|
+
/>
|
|
508
|
+
|
|
509
|
+
{/* Video slot — empty placeholder; the persistent iframe overlays here */}
|
|
510
|
+
<div className="relative px-3 pt-3">
|
|
511
|
+
<div
|
|
512
|
+
ref={videoSlotRef}
|
|
513
|
+
className="relative aspect-video w-full overflow-hidden rounded-2xl ring-1 ring-black/10 dark:ring-white/10 bg-black shadow-[0_8px_24px_-12px_rgba(0,0,0,0.5)]"
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
<div className="relative flex items-center gap-3 px-4 pt-3 pb-2">
|
|
518
|
+
<PlayPauseButton
|
|
519
|
+
playing={playing}
|
|
520
|
+
onClick={onTogglePlay}
|
|
521
|
+
size="md"
|
|
522
|
+
copy={copy}
|
|
523
|
+
/>
|
|
524
|
+
<div className="min-w-0 flex-1">
|
|
525
|
+
<div className="flex items-center gap-1.5">
|
|
526
|
+
<LivePulse />
|
|
527
|
+
<span className="text-[10px] uppercase tracking-[0.22em] font-semibold text-rose-500 dark:text-rose-400">
|
|
528
|
+
{copy.onAir}
|
|
529
|
+
</span>
|
|
530
|
+
</div>
|
|
531
|
+
<div className="mt-0.5 font-knick text-[15px] leading-tight truncate text-purpleDanis dark:text-white">
|
|
532
|
+
{channel?.emoji} {channel?.name}
|
|
533
|
+
</div>
|
|
534
|
+
<div className="text-[10.5px] truncate text-purpleDanis/60 dark:text-white/55">
|
|
535
|
+
{channel?.mood}
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
<button
|
|
539
|
+
type="button"
|
|
540
|
+
onClick={onClose}
|
|
541
|
+
aria-label={copy.collapseRadio}
|
|
542
|
+
className={cn(
|
|
543
|
+
"h-7 w-7 grid place-items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2",
|
|
544
|
+
"text-purpleDanis/70 hover:text-purpleDanis hover:bg-purpleDanis/10 focus-visible:ring-purpleDanis/40",
|
|
545
|
+
"dark:text-white/70 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:ring-white/40",
|
|
546
|
+
)}
|
|
547
|
+
>
|
|
548
|
+
<X className="h-3.5 w-3.5" strokeWidth={2.4} />
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<ChannelScroller
|
|
553
|
+
ref={scrollerRef}
|
|
554
|
+
channels={channels}
|
|
555
|
+
copy={copy}
|
|
556
|
+
activeChannelId={channel?.id ?? ""}
|
|
557
|
+
onSelect={onChannelSelect}
|
|
558
|
+
/>
|
|
559
|
+
</div>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function MarqueeRow({ channel }: { channel: Channel | undefined }) {
|
|
564
|
+
return (
|
|
565
|
+
<span className="font-knick text-[11px] tracking-[0.08em] text-purpleDanis dark:text-white shrink-0 pr-6">
|
|
566
|
+
BUFI Radio
|
|
567
|
+
<span className="mx-2 opacity-45">·</span>
|
|
568
|
+
<span aria-hidden>{channel?.emoji}</span>{" "}
|
|
569
|
+
<span className="text-purpleDanis dark:text-white">{channel?.name}</span>
|
|
570
|
+
<span className="mx-2 opacity-45">·</span>
|
|
571
|
+
<span className="text-rose-500 dark:text-rose-400 font-knick">LIVE</span>
|
|
572
|
+
<span className="mx-1.5 opacity-45">on</span>
|
|
573
|
+
<span className="opacity-80">YouTube</span>
|
|
574
|
+
</span>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function PlayPauseButton({
|
|
579
|
+
playing,
|
|
580
|
+
onClick,
|
|
581
|
+
size,
|
|
582
|
+
copy,
|
|
583
|
+
}: {
|
|
584
|
+
playing: boolean;
|
|
585
|
+
onClick: () => void;
|
|
586
|
+
size: "sm" | "md";
|
|
587
|
+
copy: RadioCopy;
|
|
588
|
+
}) {
|
|
589
|
+
const sizeClass = size === "sm" ? "h-7 w-7" : "h-9 w-9";
|
|
590
|
+
const iconClass = size === "sm" ? "h-3 w-3" : "h-4 w-4";
|
|
591
|
+
return (
|
|
592
|
+
<motion.button
|
|
593
|
+
type="button"
|
|
594
|
+
onClick={(e) => {
|
|
595
|
+
e.stopPropagation();
|
|
596
|
+
onClick();
|
|
597
|
+
}}
|
|
598
|
+
whileTap={{ scale: 0.9 }}
|
|
599
|
+
transition={{ type: "spring", stiffness: 700, damping: 22 }}
|
|
600
|
+
aria-label={playing ? copy.pause : copy.play}
|
|
601
|
+
className={cn(
|
|
602
|
+
sizeClass,
|
|
603
|
+
"shrink-0 grid place-items-center rounded-full shadow-[0_2px_8px_-2px_rgba(105,84,207,0.5)] transition-colors focus-visible:outline-none focus-visible:ring-2",
|
|
604
|
+
"bg-purpleDanis text-white hover:bg-[#7864E0] focus-visible:ring-purpleDanis/50",
|
|
605
|
+
"dark:bg-white dark:text-purpleDanis dark:hover:bg-white dark:focus-visible:ring-white/60",
|
|
606
|
+
)}
|
|
607
|
+
>
|
|
608
|
+
<AnimatePresence initial={false} mode="wait">
|
|
609
|
+
{playing ? (
|
|
610
|
+
<motion.span
|
|
611
|
+
key="pause"
|
|
612
|
+
initial={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
|
|
613
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
614
|
+
exit={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
|
|
615
|
+
transition={{ duration: 0.12 }}
|
|
616
|
+
>
|
|
617
|
+
<Pause className={cn(iconClass, "fill-current")} />
|
|
618
|
+
</motion.span>
|
|
619
|
+
) : (
|
|
620
|
+
<motion.span
|
|
621
|
+
key="play"
|
|
622
|
+
initial={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
|
|
623
|
+
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
|
624
|
+
exit={{ opacity: 0, scale: 0.6, filter: "blur(3px)" }}
|
|
625
|
+
transition={{ duration: 0.12 }}
|
|
626
|
+
>
|
|
627
|
+
<Play className={cn(iconClass, "fill-current translate-x-[1px]")} />
|
|
628
|
+
</motion.span>
|
|
629
|
+
)}
|
|
630
|
+
</AnimatePresence>
|
|
631
|
+
</motion.button>
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function LivePulse() {
|
|
636
|
+
return (
|
|
637
|
+
<span className="relative grid place-items-center">
|
|
638
|
+
<span className="absolute inline-flex h-2 w-2 rounded-full bg-rose-400 dark:bg-rose-500 opacity-60 animate-ping" />
|
|
639
|
+
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-rose-500 dark:bg-rose-400" />
|
|
640
|
+
</span>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const ChannelScroller = ({
|
|
645
|
+
channels,
|
|
646
|
+
copy,
|
|
647
|
+
activeChannelId,
|
|
648
|
+
onSelect,
|
|
649
|
+
ref,
|
|
650
|
+
}: {
|
|
651
|
+
channels: Channel[];
|
|
652
|
+
copy: RadioCopy;
|
|
653
|
+
activeChannelId: string;
|
|
654
|
+
onSelect: (id: string) => void;
|
|
655
|
+
ref: React.RefObject<HTMLDivElement | null>;
|
|
656
|
+
}) => {
|
|
657
|
+
return (
|
|
658
|
+
<div className="relative">
|
|
659
|
+
<div
|
|
660
|
+
className="pointer-events-none absolute left-0 top-0 bottom-2 w-6 z-10 bg-gradient-to-r from-white/95 dark:from-black/95 to-transparent"
|
|
661
|
+
aria-hidden
|
|
662
|
+
/>
|
|
663
|
+
<div
|
|
664
|
+
className="pointer-events-none absolute right-0 top-0 bottom-2 w-6 z-10 bg-gradient-to-l from-white/95 dark:from-black/95 to-transparent"
|
|
665
|
+
aria-hidden
|
|
666
|
+
/>
|
|
667
|
+
<div
|
|
668
|
+
ref={ref}
|
|
669
|
+
className="overflow-x-auto overflow-y-hidden scrollbar-none px-3 pb-3 pt-1"
|
|
670
|
+
style={{ scrollbarWidth: "none" }}
|
|
671
|
+
>
|
|
672
|
+
<div className="flex gap-1.5 min-w-max">
|
|
673
|
+
{channels.map((c) => {
|
|
674
|
+
const active = c.id === activeChannelId;
|
|
675
|
+
return (
|
|
676
|
+
<motion.button
|
|
677
|
+
key={c.id}
|
|
678
|
+
data-channel-id={c.id}
|
|
679
|
+
type="button"
|
|
680
|
+
onClick={() => onSelect(c.id)}
|
|
681
|
+
whileTap={{ scale: 0.93 }}
|
|
682
|
+
transition={{ type: "spring", stiffness: 700, damping: 22 }}
|
|
683
|
+
className={cn(
|
|
684
|
+
"shrink-0 w-[72px] h-[72px] rounded-2xl flex flex-col items-center justify-center gap-1 text-[10px] font-medium ring-1 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2",
|
|
685
|
+
active
|
|
686
|
+
? [
|
|
687
|
+
"text-white ring-transparent shadow-[0_8px_22px_-8px_rgba(105,84,207,0.6)] focus-visible:ring-purpleDanis/60",
|
|
688
|
+
"dark:text-purpleDanis dark:focus-visible:ring-white/70",
|
|
689
|
+
]
|
|
690
|
+
: [
|
|
691
|
+
"bg-purpleDanis/[0.05] text-purpleDanis/80 ring-purpleDanis/15 hover:bg-purpleDanis/[0.1] hover:text-purpleDanis focus-visible:ring-purpleDanis/40",
|
|
692
|
+
"dark:bg-white/[0.04] dark:text-white/70 dark:ring-white/10 dark:hover:bg-white/[0.08] dark:hover:text-white dark:focus-visible:ring-white/60",
|
|
693
|
+
],
|
|
694
|
+
)}
|
|
695
|
+
style={
|
|
696
|
+
active
|
|
697
|
+
? {
|
|
698
|
+
background: `linear-gradient(135deg, ${c.accent}ee 0%, ${c.accent}cc 60%, ${c.accent}aa 100%)`,
|
|
699
|
+
}
|
|
700
|
+
: undefined
|
|
701
|
+
}
|
|
702
|
+
aria-label={interp(copy.switchTo, { name: c.name })}
|
|
703
|
+
aria-pressed={active}
|
|
704
|
+
>
|
|
705
|
+
<span
|
|
706
|
+
className="text-base leading-none drop-shadow-sm"
|
|
707
|
+
aria-hidden
|
|
708
|
+
>
|
|
709
|
+
{c.emoji}
|
|
710
|
+
</span>
|
|
711
|
+
<span className="font-knick leading-tight text-center px-1 line-clamp-2">
|
|
712
|
+
{c.name}
|
|
713
|
+
</span>
|
|
714
|
+
</motion.button>
|
|
715
|
+
);
|
|
716
|
+
})}
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
function IconButton({
|
|
724
|
+
children,
|
|
725
|
+
onClick,
|
|
726
|
+
ariaLabel,
|
|
727
|
+
className,
|
|
728
|
+
}: {
|
|
729
|
+
children: React.ReactNode;
|
|
730
|
+
onClick: () => void;
|
|
731
|
+
ariaLabel: string;
|
|
732
|
+
className?: string;
|
|
733
|
+
}) {
|
|
734
|
+
return (
|
|
735
|
+
<motion.button
|
|
736
|
+
type="button"
|
|
737
|
+
onClick={onClick}
|
|
738
|
+
aria-label={ariaLabel}
|
|
739
|
+
whileTap={{ scale: 0.9 }}
|
|
740
|
+
transition={{ type: "spring", stiffness: 700, damping: 22 }}
|
|
741
|
+
className={cn(
|
|
742
|
+
"p-1.5 rounded-full transition-colors duration-200 hover:bg-purpleDanis/10 dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purpleDanis/40 dark:focus-visible:ring-white/40",
|
|
743
|
+
className,
|
|
744
|
+
)}
|
|
745
|
+
>
|
|
746
|
+
{children}
|
|
747
|
+
</motion.button>
|
|
748
|
+
);
|
|
749
|
+
}
|