@proteos/ui 0.77.0 → 0.78.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.
Files changed (32) hide show
  1. package/dist/index.cjs +991 -5
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +592 -2
  4. package/dist/index.d.ts +592 -2
  5. package/dist/index.js +969 -9
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -2
  8. package/src/components/data-table/data-table.scroll-restore.test.tsx +97 -0
  9. package/src/components/data-table/data-table.test.tsx +22 -2
  10. package/src/components/data-table/data-table.tsx +31 -1
  11. package/src/components/frame-strip/frame-strip.stories.tsx +61 -0
  12. package/src/components/frame-strip/frame-strip.test.tsx +112 -0
  13. package/src/components/frame-strip/frame-strip.tsx +131 -0
  14. package/src/components/frame-strip/index.ts +2 -0
  15. package/src/components/media-player/index.ts +22 -0
  16. package/src/components/media-player/media-controls.tsx +145 -0
  17. package/src/components/media-player/media-markers.tsx +108 -0
  18. package/src/components/media-player/media-peaks.test.ts +68 -0
  19. package/src/components/media-player/media-peaks.ts +78 -0
  20. package/src/components/media-player/media-player.stories.tsx +243 -0
  21. package/src/components/media-player/media-player.test.tsx +318 -0
  22. package/src/components/media-player/media-player.tsx +334 -0
  23. package/src/components/media-player/media-time.ts +27 -0
  24. package/src/components/media-player/media-timeline.test.tsx +133 -0
  25. package/src/components/media-player/media-timeline.tsx +78 -0
  26. package/src/components/media-player/media-video-stage.tsx +113 -0
  27. package/src/components/media-player/media-waveform.tsx +330 -0
  28. package/src/components/slider/index.ts +2 -0
  29. package/src/components/slider/slider.stories.tsx +32 -0
  30. package/src/components/slider/slider.test.tsx +35 -0
  31. package/src/components/slider/slider.tsx +89 -0
  32. package/src/index.ts +3 -0
@@ -0,0 +1,108 @@
1
+ import { Button as AriaButton } from 'react-aria-components'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+ import { formatMediaTime } from './media-time'
6
+
7
+ /* =========================================================================
8
+ MediaMarkerTrack — named moments on a time axis: a heading row (what the
9
+ markers are, which ones hold the playhead, how many) over a thin track of
10
+ press-to-seek bars. Shared by the waveform and the timeline so a marker
11
+ looks and reads the same whichever picture sits above it.
12
+ ========================================================================= */
13
+
14
+ export const mediaMarkersStyles = tv({
15
+ slots: {
16
+ root: 'flex shrink-0 flex-col gap-[3px]',
17
+ heading: 'flex min-w-0 shrink-0 items-center gap-1.5 text-[12px] leading-4 text-ink-3',
18
+ dot: 'size-[5px] shrink-0 rounded-pill',
19
+ current: 'min-w-0 truncate font-medium text-ink',
20
+ count: 'ml-auto shrink-0 font-mono text-[11px] text-ink-4',
21
+ track: 'relative h-3',
22
+ marker: [
23
+ 'absolute top-0.5 h-2 min-w-1.5 cursor-pointer rounded-sm border p-0 outline-none',
24
+ 'border-[color-mix(in_oklab,var(--color-accent)_45%,var(--color-rule))]',
25
+ 'bg-[color-mix(in_oklab,var(--color-accent)_14%,var(--color-surface))]',
26
+ 'transition-colors duration-150',
27
+ 'hovered:border-accent hovered:bg-accent',
28
+ 'data-[current]:border-accent data-[current]:bg-accent',
29
+ 'data-[focus-visible]:shadow-[var(--shadow-focus)]',
30
+ // The bar is thin; the press target isn't.
31
+ "after:absolute after:inset-x-0 after:-inset-y-2 after:content-['']",
32
+ ],
33
+ },
34
+ })
35
+
36
+ export type MediaMarkersVariants = VariantProps<typeof mediaMarkersStyles>
37
+
38
+ export interface MediaMarker {
39
+ key: string
40
+ label: string
41
+ /** Seconds. A moment rather than a span has `end` equal to `start`. */
42
+ start: number
43
+ end: number
44
+ }
45
+
46
+ /** The markers whose span holds `time`. */
47
+ export function currentMediaMarkers(
48
+ markers: ReadonlyArray<MediaMarker>,
49
+ time: number,
50
+ ): MediaMarker[] {
51
+ return markers.filter((marker) => time >= marker.start && time < marker.end)
52
+ }
53
+
54
+ export interface MediaMarkerTrackProps {
55
+ markers: ReadonlyArray<MediaMarker>
56
+ /** Heading of the track. */
57
+ label?: string
58
+ currentTime: number
59
+ /** Seconds across the whole track; the bars hide until it is known. */
60
+ duration: number
61
+ onSeek: (seconds: number) => void
62
+ className?: string
63
+ }
64
+
65
+ export function MediaMarkerTrack({
66
+ markers,
67
+ label = 'Markers',
68
+ currentTime,
69
+ duration,
70
+ onSeek,
71
+ className,
72
+ }: MediaMarkerTrackProps) {
73
+ const styles = mediaMarkersStyles()
74
+ const current = currentMediaMarkers(markers, currentTime)
75
+
76
+ return (
77
+ <div className={cn(styles.root(), className)}>
78
+ <div className={styles.heading()}>
79
+ <span aria-hidden className={styles.dot()} />
80
+ <span className="shrink-0">{label}</span>
81
+ <span className={styles.current()}>
82
+ {current.map((marker) => marker.label).join(' · ')}
83
+ </span>
84
+ <span className={styles.count()}>{markers.length}</span>
85
+ </div>
86
+ <div className={styles.track()}>
87
+ {markers.map((marker) => (
88
+ <AriaButton
89
+ key={marker.key}
90
+ data-media-marker={marker.key}
91
+ data-current={current.includes(marker) || undefined}
92
+ aria-label={`${marker.label}, at ${formatMediaTime(marker.start)}`}
93
+ onPress={() => onSeek(marker.start)}
94
+ className={styles.marker()}
95
+ style={
96
+ duration > 0
97
+ ? {
98
+ left: `${(marker.start / duration) * 100}%`,
99
+ width: `${((marker.end - marker.start) / duration) * 100}%`,
100
+ }
101
+ : { display: 'none' }
102
+ }
103
+ />
104
+ ))}
105
+ </div>
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { maskPeaks, peaksFromChannels } from './media-peaks'
4
+ import { formatMediaTime, spokenMediaTime } from './media-time'
5
+
6
+ describe('peaksFromChannels', () => {
7
+ it('scales the loudest bucket to 1', () => {
8
+ const channel = new Float32Array([0.1, 0.1, 0.5, 0.5])
9
+ const peaks = peaksFromChannels([channel], 2)
10
+ expect(peaks[1]).toBeCloseTo(1)
11
+ expect(peaks[0]).toBeCloseTo(0.2)
12
+ })
13
+
14
+ it('hears both channels of a stereo recording', () => {
15
+ const left = new Float32Array([0.5, 0.5, 0, 0])
16
+ const right = new Float32Array([0, 0, 0.5, 0.5])
17
+ const peaks = peaksFromChannels([left, right], 2)
18
+ expect(peaks[0]).toBeCloseTo(1)
19
+ expect(peaks[1]).toBeCloseTo(1)
20
+ })
21
+
22
+ it('stays silent for empty or silent audio', () => {
23
+ expect(Array.from(peaksFromChannels([], 2))).toEqual([0, 0])
24
+ expect(Array.from(peaksFromChannels([new Float32Array(4)], 2))).toEqual([0, 0])
25
+ })
26
+ })
27
+
28
+ describe('maskPeaks', () => {
29
+ const peaks = new Float32Array([0.5, 0.5, 0.5, 0.5])
30
+
31
+ it('keeps only the segments', () => {
32
+ expect(Array.from(maskPeaks(peaks, 4, [[1, 2]]))).toEqual([0, 0.5, 0, 0])
33
+ })
34
+
35
+ it('combines several segments', () => {
36
+ expect(
37
+ Array.from(
38
+ maskPeaks(peaks, 4, [
39
+ [0, 1],
40
+ [3, 4],
41
+ ]),
42
+ ),
43
+ ).toEqual([0.5, 0, 0, 0.5])
44
+ })
45
+
46
+ it('tolerates segments past the recording', () => {
47
+ expect(Array.from(maskPeaks(peaks, 4, [[3, 99]]))).toEqual([0, 0, 0, 0.5])
48
+ })
49
+
50
+ it('is silent without a duration', () => {
51
+ expect(Array.from(maskPeaks(peaks, 0, [[0, 1]]))).toEqual([0, 0, 0, 0])
52
+ })
53
+ })
54
+
55
+ describe('media time', () => {
56
+ it('formats minutes and hours', () => {
57
+ expect(formatMediaTime(156)).toBe('02:36')
58
+ expect(formatMediaTime(3756)).toBe('1:02:36')
59
+ expect(formatMediaTime(Number.NaN)).toBe('00:00')
60
+ })
61
+
62
+ it('speaks units', () => {
63
+ expect(spokenMediaTime(156)).toBe('2 minutes 36 seconds')
64
+ expect(spokenMediaTime(61)).toBe('1 minute 1 second')
65
+ expect(spokenMediaTime(0)).toBe('0 seconds')
66
+ expect(spokenMediaTime(3600)).toBe('1 hour')
67
+ })
68
+ })
@@ -0,0 +1,78 @@
1
+ /* =========================================================================
2
+ Waveform data. Peaks are one loudness curve for the whole recording, 0..1
3
+ and normalized once, so every lane drawn from them shares one scale.
4
+ ========================================================================= */
5
+
6
+ /** Above this the browser would hold too much decoded audio: play without bars. */
7
+ export const MAX_DECODE_BYTES = 50 * 1024 * 1024
8
+
9
+ /** Enough resolution to draw and far less memory than the source rate. */
10
+ const DECODE_SAMPLE_RATE = 8000
11
+ const PEAK_BUCKETS = 4000
12
+ /** Samples read per bucket: the loudness of a bucket doesn't need all of them. */
13
+ const SAMPLES_PER_BUCKET = 256
14
+
15
+ export interface DecodedPeaks {
16
+ peaks: Float32Array
17
+ duration: number
18
+ }
19
+
20
+ /** A time range in seconds. */
21
+ export type MediaSegment = readonly [start: number, end: number]
22
+
23
+ /** RMS per bucket across all channels, scaled so the loudest bucket is 1. */
24
+ export function peaksFromChannels(
25
+ channels: ReadonlyArray<Float32Array>,
26
+ buckets: number = PEAK_BUCKETS,
27
+ ): Float32Array {
28
+ const length = channels[0]?.length ?? 0
29
+ const peaks = new Float32Array(buckets)
30
+ if (length === 0) return peaks
31
+
32
+ const stride = Math.max(1, Math.floor(length / buckets / SAMPLES_PER_BUCKET))
33
+ let loudest = 0
34
+ for (let bucket = 0; bucket < buckets; bucket++) {
35
+ const start = Math.floor((bucket / buckets) * length)
36
+ const end = Math.floor(((bucket + 1) / buckets) * length)
37
+ let sum = 0
38
+ let count = 0
39
+ for (let index = start; index < end; index += stride) {
40
+ let sample = 0
41
+ for (const channel of channels) sample = Math.max(sample, Math.abs(channel[index] ?? 0))
42
+ sum += sample * sample
43
+ count++
44
+ }
45
+ const level = count > 0 ? Math.sqrt(sum / count) : 0
46
+ peaks[bucket] = level
47
+ if (level > loudest) loudest = level
48
+ }
49
+ if (loudest > 0)
50
+ for (let bucket = 0; bucket < buckets; bucket++) peaks[bucket] = (peaks[bucket] ?? 0) / loudest
51
+ return peaks
52
+ }
53
+
54
+ /** The curve with everything outside `segments` silenced. */
55
+ export function maskPeaks(
56
+ peaks: Float32Array,
57
+ duration: number,
58
+ segments: ReadonlyArray<MediaSegment>,
59
+ ): Float32Array {
60
+ const masked = new Float32Array(peaks.length)
61
+ if (duration <= 0) return masked
62
+ for (const [start, end] of segments) {
63
+ const from = Math.max(0, Math.floor((start / duration) * peaks.length))
64
+ const to = Math.min(peaks.length, Math.ceil((end / duration) * peaks.length))
65
+ for (let bucket = from; bucket < to; bucket++) masked[bucket] = peaks[bucket] ?? 0
66
+ }
67
+ return masked
68
+ }
69
+
70
+ /** Decodes without an output device, so it needs no user gesture. */
71
+ export async function decodePeaks(blob: Blob): Promise<DecodedPeaks> {
72
+ const context = new OfflineAudioContext(1, 1, DECODE_SAMPLE_RATE)
73
+ const buffer = await context.decodeAudioData(await blob.arrayBuffer())
74
+ const channels = Array.from({ length: buffer.numberOfChannels }, (_, index) =>
75
+ buffer.getChannelData(index),
76
+ )
77
+ return { peaks: peaksFromChannels(channels), duration: buffer.duration }
78
+ }
@@ -0,0 +1,243 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useEffect, useMemo, useState } from 'react'
3
+
4
+ import { FrameStrip } from '../frame-strip/frame-strip'
5
+ import { MediaControls } from './media-controls'
6
+ import type { MediaMarker } from './media-markers'
7
+ import { MediaPlayer, MediaStage, useMediaPlayer } from './media-player'
8
+ import { MediaTimeline } from './media-timeline'
9
+ import { MediaVideoStage } from './media-video-stage'
10
+ import { MediaWaveform } from './media-waveform'
11
+
12
+ const RATE = 8000
13
+ const SECONDS = 60
14
+
15
+ /** One mono mix of three alternating tones, as a WAV. */
16
+ function makeRecording(): Blob {
17
+ const samples = RATE * SECONDS
18
+ const buffer = new ArrayBuffer(44 + samples * 2)
19
+ const view = new DataView(buffer)
20
+ const write = (offset: number, text: string) => {
21
+ for (const [index, char] of [...text].entries())
22
+ view.setUint8(offset + index, char.charCodeAt(0))
23
+ }
24
+ write(0, 'RIFF')
25
+ view.setUint32(4, 36 + samples * 2, true)
26
+ write(8, 'WAVEfmt ')
27
+ view.setUint32(16, 16, true)
28
+ view.setUint16(20, 1, true)
29
+ view.setUint16(22, 1, true)
30
+ view.setUint32(24, RATE, true)
31
+ view.setUint32(28, RATE * 2, true)
32
+ view.setUint16(32, 2, true)
33
+ view.setUint16(34, 16, true)
34
+ write(36, 'data')
35
+ view.setUint32(40, samples * 2, true)
36
+ for (let index = 0; index < samples; index++) {
37
+ const time = index / RATE
38
+ const turn = Math.floor(time / 5) % 3
39
+ const gate = Math.max(0, Math.sin(time * 7)) * (time % 5 < 4.4 ? 1 : 0)
40
+ const value = gate * 0.25 * Math.sin(2 * Math.PI * ([122, 196, 158][turn] ?? 122) * time)
41
+ view.setInt16(44 + index * 2, value * 32767, true)
42
+ }
43
+ return new Blob([buffer], { type: 'audio/wav' })
44
+ }
45
+
46
+ const turnsOf = (speaker: number) =>
47
+ Array.from({ length: 12 }, (_, index) => [index * 5, index * 5 + 4.4] as const).filter(
48
+ (_, index) => index % 3 === speaker,
49
+ )
50
+
51
+ const TAGS: MediaMarker[] = [
52
+ { key: 'renewal', label: 'Renewal intent', start: 5, end: 14 },
53
+ { key: 'pricing', label: 'Pricing objection', start: 30, end: 44 },
54
+ ]
55
+
56
+ function Frame({ lanes, markers }: { lanes?: boolean; markers?: boolean }) {
57
+ const source = useMemo(() => ({ blob: makeRecording() }), [])
58
+ return (
59
+ <div className="flex h-[520px] w-[420px] flex-col border border-rule bg-surface">
60
+ <MediaPlayer source={source} label="Call recording">
61
+ <MediaStage>
62
+ <MediaWaveform
63
+ {...(lanes
64
+ ? {
65
+ lanes: [
66
+ { key: 'you', label: 'You', meta: '00:18', segments: turnsOf(0) },
67
+ { key: 'dana', label: 'Dana Whitfield', meta: '00:18', segments: turnsOf(1) },
68
+ { key: 'marek', label: 'Marek Novak', meta: '00:18', segments: turnsOf(2) },
69
+ ],
70
+ }
71
+ : {})}
72
+ {...(markers ? { markers: TAGS, markersLabel: 'Tags' } : {})}
73
+ />
74
+ </MediaStage>
75
+ <MediaControls />
76
+ </MediaPlayer>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ /** The same recording on a bare axis: no bars, markers and seeking only. */
82
+ function TimelineFrame() {
83
+ const source = useMemo(() => ({ blob: makeRecording() }), [])
84
+ return (
85
+ <div className="flex w-[420px] flex-col border border-rule bg-surface">
86
+ <MediaPlayer source={source} label="Call recording">
87
+ <MediaTimeline markers={TAGS} markersLabel="Tags" />
88
+ <MediaControls />
89
+ </MediaPlayer>
90
+ </div>
91
+ )
92
+ }
93
+
94
+ /* ---- Video -------------------------------------------------------------
95
+ A few seconds of a moving square recorded from a canvas: a real
96
+ MediaRecorder webm, whose container reports no length — the case the
97
+ source's `duration` exists for.
98
+ ----------------------------------------------------------------------- */
99
+
100
+ const VIDEO_SECONDS = 6
101
+ const VIDEO_WIDTH = 640
102
+ const VIDEO_HEIGHT = 360
103
+
104
+ function useCanvasRecording(): Blob | null {
105
+ const [blob, setBlob] = useState<Blob | null>(null)
106
+ useEffect(() => {
107
+ if (typeof MediaRecorder === 'undefined') return
108
+ const canvas = document.createElement('canvas')
109
+ canvas.width = VIDEO_WIDTH
110
+ canvas.height = VIDEO_HEIGHT
111
+ const context = canvas.getContext('2d')
112
+ if (!context) return
113
+ const recorder = new MediaRecorder(canvas.captureStream(30))
114
+ const chunks: Blob[] = []
115
+ recorder.ondataavailable = (event) => chunks.push(event.data)
116
+ recorder.onstop = () => setBlob(new Blob(chunks, { type: recorder.mimeType }))
117
+ const started = performance.now()
118
+ let frame = 0
119
+ const draw = () => {
120
+ const time = (performance.now() - started) / 1000
121
+ context.fillStyle = '#f4f4f6'
122
+ context.fillRect(0, 0, VIDEO_WIDTH, VIDEO_HEIGHT)
123
+ context.fillStyle = '#5b5bd6'
124
+ context.fillRect(40 + ((time * 90) % 520), 140, 80, 80)
125
+ context.fillStyle = '#17171c'
126
+ context.font = '28px monospace'
127
+ context.fillText(`${time.toFixed(1)}s`, 24, 44)
128
+ if (time < VIDEO_SECONDS) frame = requestAnimationFrame(draw)
129
+ else recorder.stop()
130
+ }
131
+ recorder.start()
132
+ frame = requestAnimationFrame(draw)
133
+ return () => {
134
+ cancelAnimationFrame(frame)
135
+ if (recorder.state !== 'inactive') recorder.stop()
136
+ }
137
+ }, [])
138
+ return blob
139
+ }
140
+
141
+ /** Frames: a moment each, with a normalized box the overlay draws while it is current. */
142
+ const FRAMES = [
143
+ { key: 'f1', label: 'Frame 1', at: 1.4, box: [0.1, 0.35, 0.22, 0.3] },
144
+ { key: 'f2', label: 'Frame 2', at: 3.1, box: [0.4, 0.35, 0.22, 0.3] },
145
+ { key: 'f3', label: 'Frame 3', at: 4.8, box: [0.68, 0.35, 0.22, 0.3] },
146
+ ] as const
147
+
148
+ const FRAME_MARKERS: MediaMarker[] = FRAMES.map((frame) => ({
149
+ key: frame.key,
150
+ label: frame.label,
151
+ start: frame.at,
152
+ end: frame.at + 1,
153
+ }))
154
+
155
+ /** Drawn over the picture in 0..1 coordinates, so it follows the box's fit. */
156
+ function InkOverlay() {
157
+ const { currentTime } = useMediaPlayer()
158
+ const current = FRAMES.find((frame) => currentTime >= frame.at && currentTime < frame.at + 1)
159
+ if (!current) return null
160
+ const [x, y, width, height] = current.box
161
+ return (
162
+ <svg viewBox="0 0 1 1" preserveAspectRatio="none" className="size-full" aria-hidden>
163
+ <rect
164
+ x={x}
165
+ y={y}
166
+ width={width}
167
+ height={height}
168
+ rx={0.01}
169
+ fill="none"
170
+ stroke="var(--color-accent)"
171
+ strokeWidth={3}
172
+ vectorEffect="non-scaling-stroke"
173
+ />
174
+ </svg>
175
+ )
176
+ }
177
+
178
+ function FrameStripSeeking() {
179
+ const { currentTime, seek } = useMediaPlayer()
180
+ const [selectedKey, setSelectedKey] = useState<string>(FRAMES[0].key)
181
+ // Playback reaching a frame selects it; picking a frame seeks to it.
182
+ useEffect(() => {
183
+ const reached = [...FRAMES].reverse().find((frame) => currentTime >= frame.at)
184
+ if (reached) setSelectedKey(reached.key)
185
+ }, [currentTime])
186
+ return (
187
+ <FrameStrip
188
+ aria-label="Frames"
189
+ items={FRAMES.map((frame) => ({
190
+ key: frame.key,
191
+ label: frame.label,
192
+ at: frame.at,
193
+ thumbnail: (
194
+ <svg viewBox="0 0 16 9" aria-hidden>
195
+ <rect width="16" height="9" fill="#f4f4f6" />
196
+ <rect x={1 + frame.box[0] * 14} y="3.5" width="2" height="2" fill="#5b5bd6" />
197
+ </svg>
198
+ ),
199
+ }))}
200
+ selectedKey={selectedKey}
201
+ onSelect={(key) => {
202
+ setSelectedKey(key)
203
+ const frame = FRAMES.find((entry) => entry.key === key)
204
+ if (frame) seek(frame.at)
205
+ }}
206
+ />
207
+ )
208
+ }
209
+
210
+ function VideoFrame() {
211
+ const blob = useCanvasRecording()
212
+ const source = useMemo(() => (blob ? { blob, duration: VIDEO_SECONDS } : null), [blob])
213
+ if (!source) {
214
+ return <p className="text-[13px] text-ink-3">Recording {VIDEO_SECONDS} seconds of canvas…</p>
215
+ }
216
+ return (
217
+ <div className="flex h-[560px] w-[520px] flex-col border border-rule bg-surface">
218
+ <MediaPlayer source={source} label="Screen recording" kind="video">
219
+ <MediaVideoStage className="p-[18px]">
220
+ <InkOverlay />
221
+ </MediaVideoStage>
222
+ <MediaTimeline markers={FRAME_MARKERS} markersLabel="Frames" />
223
+ <FrameStripSeeking />
224
+ <MediaControls />
225
+ </MediaPlayer>
226
+ </div>
227
+ )
228
+ }
229
+
230
+ const meta: Meta<typeof MediaPlayer> = {
231
+ title: 'Components/MediaPlayer',
232
+ component: MediaPlayer,
233
+ tags: ['autodocs'],
234
+ }
235
+
236
+ export default meta
237
+ type Story = StoryObj<typeof MediaPlayer>
238
+
239
+ export const SingleLane: Story = { render: () => <Frame /> }
240
+ export const SpeakerLanes: Story = { render: () => <Frame lanes /> }
241
+ export const LanesWithMarkers: Story = { render: () => <Frame lanes markers /> }
242
+ export const Timeline: Story = { render: () => <TimelineFrame /> }
243
+ export const Video: Story = { render: () => <VideoFrame /> }