@columenlabs/lumen-ui 0.1.159 → 0.1.161

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,244 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // LumenPlayer: a recording, video or audio, on the browser's own <video>/<audio> (served with byte
3
+ // ranges, so seeking reads only what it needs) with the kit's controls under it: play, back and
4
+ // forward 15 s, the seek bar and its times, speed, captions and full screen. Chapters jump; a timed
5
+ // transcript lights the line being said, keeps it in view inside its own box and plays from a
6
+ // line on a tap. It starts at `start` and reports its place through `onProgress` about every 10 s
7
+ // while it plays, on pause and at the end, so an app can save where it was left. A slides source
8
+ // is pictures in step with an audio track: the slide shown is the last one whose `at` has passed.
9
+ // Keys work only while the player has focus: space or k plays, j and l skip 15 s, ← and → 5 s,
10
+ // f full screen, c captions.
11
+ import { CaptionsIcon, GaugeIcon, MaximizeIcon, MinimizeIcon, PauseIcon, PlayIcon, RotateCcwIcon, RotateCwIcon, } from 'lucide-react';
12
+ import { useEffect, useRef, useState, } from 'react';
13
+ import { cn } from './lib/utils.js';
14
+ import { LumenSelector } from './lumen-selector.js';
15
+ import { IconButton } from './page.js';
16
+ import { Slider } from './ui/slider.js';
17
+ import { Spinner } from './ui/spinner.js';
18
+ const SPEEDS = ['0.75', '1', '1.25', '1.5', '1.75', '2'];
19
+ const SKIP = 15;
20
+ const NUDGE = 5;
21
+ const REPORT_MS = 10_000;
22
+ /** After a person scrolls the transcript, it stops following for this long. */
23
+ const HANDS_OFF_MS = 5_000;
24
+ const AUDIO = /\.(mp3|m4a|aac|wav|ogg|oga|opus|flac)(\?|#|$)/i;
25
+ /** `1:02`, `1:02:03`. */
26
+ export function playerClock(seconds) {
27
+ const s = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
28
+ const h = Math.floor(s / 3600);
29
+ const m = Math.floor((s % 3600) / 60);
30
+ const ss = String(s % 60).padStart(2, '0');
31
+ return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
32
+ }
33
+ /** The last entry whose time has passed, by binary search over a list sorted by time. */
34
+ function lastAt(list, time, now) {
35
+ let lo = 0;
36
+ let hi = list.length - 1;
37
+ let at = -1;
38
+ while (lo <= hi) {
39
+ const mid = (lo + hi) >> 1;
40
+ if (time(list[mid]) <= now) {
41
+ at = mid;
42
+ lo = mid + 1;
43
+ }
44
+ else
45
+ hi = mid - 1;
46
+ }
47
+ return at;
48
+ }
49
+ export function LumenPlayer({ src, source, kind, title, poster, tracks, chapters, transcript, start, onProgress, onEnded, autoPlay, className, }) {
50
+ const root = useRef(null);
51
+ const media = useRef(null);
52
+ const url = source?.kind === 'slides' ? source.audio : (src ?? '');
53
+ const slides = source?.kind === 'slides' ? source.slides : null;
54
+ const video = !slides && (kind ? kind === 'video' : !AUDIO.test(url));
55
+ const [now, setNow] = useState(start ?? 0);
56
+ const [duration, setDuration] = useState(null);
57
+ const [playing, setPlaying] = useState(false);
58
+ const [waiting, setWaiting] = useState(false);
59
+ const [failed, setFailed] = useState(false);
60
+ const [rate, setRate] = useState('1');
61
+ const [captions, setCaptions] = useState(() => !!tracks?.some((t) => t.default));
62
+ const [full, setFull] = useState(false);
63
+ const last = useRef(0);
64
+ const progress = useRef(onProgress);
65
+ progress.current = onProgress;
66
+ const report = (ended = false) => {
67
+ const el = media.current;
68
+ // Opening a recording and leaving it unplayed saves nothing.
69
+ if (!el || !Number.isFinite(el.currentTime) || (!ended && el.currentTime < 1))
70
+ return;
71
+ last.current = Date.now();
72
+ progress.current?.(Math.round(el.currentTime), {
73
+ duration: Number.isFinite(el.duration) ? Math.round(el.duration) : null,
74
+ ended,
75
+ });
76
+ };
77
+ // Leaving the player (closing its drawer) saves the place too.
78
+ // biome-ignore lint/correctness/useExhaustiveDependencies: report reads refs only, once on leave
79
+ useEffect(() => {
80
+ const el = media.current;
81
+ return () => {
82
+ if (el && !el.ended && el.currentTime >= 1) {
83
+ progress.current?.(Math.round(el.currentTime), {
84
+ duration: Number.isFinite(el.duration) ? Math.round(el.duration) : null,
85
+ ended: false,
86
+ });
87
+ }
88
+ };
89
+ }, [url]);
90
+ useEffect(() => {
91
+ const on = () => setFull(document.fullscreenElement === root.current);
92
+ document.addEventListener('fullscreenchange', on);
93
+ return () => document.removeEventListener('fullscreenchange', on);
94
+ }, []);
95
+ // Captions follow the toggle once the tracks have loaded.
96
+ useEffect(() => {
97
+ const list = media.current?.textTracks;
98
+ if (!list)
99
+ return;
100
+ for (let i = 0; i < list.length; i++) {
101
+ const t = list[i];
102
+ if (t)
103
+ t.mode = captions && i === 0 ? 'showing' : 'hidden';
104
+ }
105
+ }, [captions]);
106
+ const seek = (s, play = false) => {
107
+ const el = media.current;
108
+ if (!el)
109
+ return;
110
+ const end = Number.isFinite(el.duration) ? el.duration : Number.POSITIVE_INFINITY;
111
+ el.currentTime = Math.min(Math.max(0, s), end);
112
+ setNow(el.currentTime);
113
+ if (play)
114
+ void el.play().catch(() => { });
115
+ };
116
+ const toggle = () => {
117
+ const el = media.current;
118
+ if (!el)
119
+ return;
120
+ if (el.paused)
121
+ void el.play().catch(() => { });
122
+ else
123
+ el.pause();
124
+ };
125
+ const fullscreen = () => {
126
+ if (document.fullscreenElement)
127
+ void document.exitFullscreen().catch(() => { });
128
+ else
129
+ void root.current?.requestFullscreen?.().catch(() => { });
130
+ };
131
+ const onKey = (e) => {
132
+ const t = e.target;
133
+ // Fields, menus and the seek bar keep their own keys.
134
+ if (t.closest('input, textarea, select, [role="slider"], [role="combobox"], [role="listbox"]'))
135
+ return;
136
+ const here = media.current?.currentTime ?? 0;
137
+ const keys = {
138
+ ' ': toggle,
139
+ k: toggle,
140
+ j: () => seek(here - SKIP),
141
+ l: () => seek(here + SKIP),
142
+ ArrowLeft: () => seek(here - NUDGE),
143
+ ArrowRight: () => seek(here + NUDGE),
144
+ ...(video ? { f: fullscreen } : {}),
145
+ ...(video && tracks?.length ? { c: () => setCaptions((c) => !c) } : {}),
146
+ };
147
+ const run = keys[e.key];
148
+ // Space on a focused button presses it; the player takes it only on itself or the media.
149
+ if (!run || e.metaKey || e.ctrlKey || e.altKey)
150
+ return;
151
+ if (e.key === ' ' && t.closest('button'))
152
+ return;
153
+ e.preventDefault();
154
+ run();
155
+ };
156
+ const events = {
157
+ ref: media,
158
+ src: url,
159
+ preload: 'metadata',
160
+ playsInline: true,
161
+ onLoadedMetadata: (e) => {
162
+ const el = e.currentTarget;
163
+ setFailed(false);
164
+ setDuration(Number.isFinite(el.duration) ? el.duration : null);
165
+ el.playbackRate = Number(rate);
166
+ if (start && start > 0 && (!Number.isFinite(el.duration) || start < el.duration - 1)) {
167
+ el.currentTime = start;
168
+ setNow(start);
169
+ }
170
+ if (autoPlay)
171
+ void el.play().catch(() => { });
172
+ },
173
+ onDurationChange: (e) => {
174
+ const d = e.currentTarget.duration;
175
+ setDuration(Number.isFinite(d) ? d : null);
176
+ },
177
+ onTimeUpdate: (e) => {
178
+ const el = e.currentTarget;
179
+ setNow(el.currentTime);
180
+ if (!el.paused && Date.now() - last.current >= REPORT_MS)
181
+ report();
182
+ },
183
+ onPlay: () => setPlaying(true),
184
+ onPause: () => {
185
+ setPlaying(false);
186
+ report();
187
+ },
188
+ onWaiting: () => setWaiting(true),
189
+ onPlaying: () => setWaiting(false),
190
+ onCanPlay: () => setWaiting(false),
191
+ onError: () => {
192
+ setWaiting(false);
193
+ setFailed(true);
194
+ },
195
+ onEnded: () => {
196
+ setPlaying(false);
197
+ report(true);
198
+ onEnded?.();
199
+ },
200
+ };
201
+ const slide = slides ? (slides[lastAt(slides, (s) => s.at, now)] ?? slides[0]) : null;
202
+ const chapter = chapters?.length ? lastAt(chapters, (c) => c.at, now) : -1;
203
+ const end = duration ?? 0;
204
+ return (_jsxs("section", { ref: root,
205
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the player takes its keys while focused
206
+ tabIndex: 0, onKeyDown: onKey, "aria-label": title ? `${title} player` : 'Player', className: cn('flex min-w-0 flex-col gap-3 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50', full && 'justify-center bg-background p-4', className), children: [_jsxs("div", { className: cn('relative overflow-hidden rounded-md', (video || slides) && 'bg-black', full && 'min-h-0 flex-1'), children: [video ? (_jsx("video", { ...events, poster: poster, title: title, onClick: toggle, className: cn('w-full', full ? 'h-full object-contain' : 'aspect-video'), children: tracks?.map((t) => (_jsx("track", { kind: "captions", src: t.src, srcLang: t.srcLang, label: t.label, default: t.default }, t.src))) }, url)) : (_jsxs(_Fragment, { children: [_jsx("audio", { ...events, title: title, className: "hidden" }, url), slide ? (_jsx("img", { src: slide.image, alt: slide.alt ?? title ?? '', className: cn('w-full object-contain', full ? 'h-full' : 'aspect-video') })) : null] })), waiting && !failed ? (_jsx("span", { className: "absolute inset-0 flex items-center justify-center bg-black/20", children: _jsx(Spinner, { className: "size-6 text-white" }) })) : null, failed ? (_jsxs("div", { role: "alert", className: cn('flex items-center justify-center gap-2 p-4 text-sm', video || slides ? 'absolute inset-0 bg-black/70 text-white' : 'text-destructive'), children: [_jsx("span", { className: "truncate", children: "Could not load this recording" }), _jsx(IconButton, { icon: RotateCwIcon, label: "Try again", variant: "secondary", onClick: () => {
207
+ setFailed(false);
208
+ media.current?.load();
209
+ } })] })) : null] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Slider, { "aria-label": "Seek", min: 0, max: end || 1, step: 1, value: [Math.min(now, end || 1)], disabled: !end, onValueChange: ([v]) => v !== undefined && seek(v), className: "py-2" }), _jsxs("div", { className: "flex min-w-0 items-center gap-1", children: [_jsx(IconButton, { icon: playing ? PauseIcon : PlayIcon, label: playing ? 'Pause' : 'Play', variant: "default", onClick: toggle, disabled: failed }), _jsx(IconButton, { icon: RotateCcwIcon, label: `Back ${SKIP} seconds`, onClick: () => seek(now - SKIP) }), _jsx(IconButton, { icon: RotateCwIcon, label: `Forward ${SKIP} seconds`, onClick: () => seek(now + SKIP) }), _jsxs("span", { className: "num ml-1 min-w-0 truncate text-xs text-muted-foreground", children: [playerClock(now), " / ", duration === null ? '–:––' : playerClock(duration)] }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx(LumenSelector, { trigger: "chip", size: "sm", icon: GaugeIcon, label: "Speed", value: rate, display: _jsxs("span", { className: "num", children: [rate, "\u00D7"] }), options: SPEEDS.map((s) => ({ value: s, label: `${s}×` })), search: false, align: "end", onChange: (v) => {
210
+ setRate(v);
211
+ if (media.current)
212
+ media.current.playbackRate = Number(v);
213
+ } }), video && tracks?.length ? (_jsx(IconButton, { icon: CaptionsIcon, label: captions ? 'Hide captions' : 'Show captions', pressed: captions, onClick: () => setCaptions((c) => !c) })) : null, video ? (_jsx(IconButton, { icon: full ? MinimizeIcon : MaximizeIcon, label: full ? 'Exit full screen' : 'Full screen', onClick: fullscreen })) : null] })] })] }), !full && chapters?.length ? (_jsx(PlayerList, { label: "Chapters", children: chapters.map((c, i) => (_jsx(PlayerRow, { at: c.at, current: i === chapter, onPlay: () => seek(c.at, true), children: c.title }, `${c.at}:${c.title}`))) })) : null, !full && transcript?.length ? (_jsx(Transcript, { lines: transcript, now: now, playing: playing, onPlay: (s) => seek(s, true) })) : null] }));
214
+ }
215
+ function PlayerList({ label, children }) {
216
+ return (_jsxs("section", { "aria-label": label, className: "flex flex-col gap-1", children: [_jsx("h3", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsx("ol", { className: "flex flex-col", children: children })] }));
217
+ }
218
+ function PlayerRow({ at, current, onPlay, children, wrap, index, }) {
219
+ return (_jsx("li", { "data-line": index, children: _jsxs("button", { type: "button", onClick: onPlay, "aria-current": current || undefined, className: cn('flex w-full min-w-0 gap-3 rounded-md px-2 py-1 text-left text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50', wrap ? 'items-baseline' : 'items-center', current && 'bg-accent text-accent-foreground'), children: [_jsx("time", { className: "num w-14 shrink-0 text-xs text-muted-foreground", children: playerClock(at) }), _jsx("span", { className: cn('min-w-0 flex-1', wrap ? 'break-words' : 'truncate'), children: children })] }) }));
220
+ }
221
+ function Transcript({ lines, now, playing, onPlay, }) {
222
+ const box = useRef(null);
223
+ const touched = useRef(0);
224
+ const at = lastAt(lines, (l) => l.start, now);
225
+ const line = lines[at];
226
+ const current = line && (line.end === undefined || now < line.end) ? at : -1;
227
+ // While it plays, the line being said stays in view inside the box (never the page), unless the
228
+ // person has just scrolled it themselves.
229
+ useEffect(() => {
230
+ const el = box.current;
231
+ if (!playing || current < 0 || !el || Date.now() - touched.current < HANDS_OFF_MS)
232
+ return;
233
+ const row = el.querySelector(`[data-line="${current}"]`);
234
+ if (!row)
235
+ return;
236
+ const top = row.offsetTop - el.offsetTop;
237
+ if (top < el.scrollTop || top + row.offsetHeight > el.scrollTop + el.clientHeight)
238
+ el.scrollTo({ top: top - el.clientHeight / 3, behavior: 'smooth' });
239
+ }, [current, playing]);
240
+ const mark = () => {
241
+ touched.current = Date.now();
242
+ };
243
+ return (_jsxs("section", { "aria-label": "Transcript", className: "flex flex-col gap-1", children: [_jsx("h3", { className: "text-xs font-medium text-muted-foreground", children: "Transcript" }), _jsx("div", { ref: box, onWheel: mark, onTouchMove: mark, className: "relative max-h-[40vh] overflow-y-auto overscroll-contain", children: _jsx("ol", { className: "flex flex-col", children: lines.map((l, i) => (_jsxs(PlayerRow, { index: i, at: l.start, current: i === current, onPlay: () => onPlay(l.start), wrap: true, children: [l.who ? _jsxs("span", { className: "font-medium", children: [l.who, ": "] }) : null, l.text] }, `${l.start}:${i}`))) }) })] }));
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.159",
3
+ "version": "0.1.161",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.159",
52
+ "@columenlabs/lumen-ui-types": "0.1.161",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",