@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.
@@ -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
+ }