@proteos/ui 0.77.0 → 0.79.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,133 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { MediaControls } from './media-controls'
6
+ import { MediaPlayer, type MediaPlayerSource } from './media-player'
7
+ import { MediaTimeline, type MediaTimelineProps } from './media-timeline'
8
+
9
+ // A screen recording has no audio to draw: the decoder rejects it.
10
+ vi.mock('./media-peaks', async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import('./media-peaks')>()),
12
+ decodePeaks: vi.fn(async () => {
13
+ throw new Error('no audio track')
14
+ }),
15
+ }))
16
+
17
+ const blob = new Blob(['video'], { type: 'video/webm' })
18
+ const markers = [
19
+ { key: 'f1', label: 'Frame 1', start: 2, end: 2 },
20
+ { key: 'f2', label: 'Frame 2', start: 4, end: 6 },
21
+ ]
22
+
23
+ function mediaElement(): HTMLMediaElement {
24
+ const element = document.querySelector('audio')
25
+ if (!element) throw new Error('no media element')
26
+ return element
27
+ }
28
+
29
+ /** jsdom has no media pipeline: stand in for what a loaded file reports. */
30
+ function loadMedia(duration: number) {
31
+ const element = mediaElement()
32
+ Object.defineProperty(element, 'duration', { configurable: true, value: duration })
33
+ let paused = true
34
+ Object.defineProperty(element, 'paused', { configurable: true, get: () => paused })
35
+ element.play = vi.fn(async () => {
36
+ paused = false
37
+ fireEvent.play(element)
38
+ })
39
+ element.pause = vi.fn(() => {
40
+ paused = true
41
+ fireEvent.pause(element)
42
+ })
43
+ fireEvent.loadedMetadata(element)
44
+ return element
45
+ }
46
+
47
+ function renderTimeline(
48
+ props: Partial<MediaTimelineProps> = {},
49
+ source: MediaPlayerSource = { blob },
50
+ ) {
51
+ return render(
52
+ <MediaPlayer source={source} label="Screen recording">
53
+ <MediaTimeline markers={markers} markersLabel="Frames" {...props} />
54
+ <MediaControls />
55
+ </MediaPlayer>,
56
+ )
57
+ }
58
+
59
+ beforeEach(() => {
60
+ URL.createObjectURL = vi.fn(() => 'blob:recording')
61
+ URL.revokeObjectURL = vi.fn()
62
+ })
63
+
64
+ describe('MediaTimeline', () => {
65
+ it('seeks through its own named slider once the media loads', () => {
66
+ renderTimeline()
67
+ const slider = screen.getByRole('slider', { name: 'Timeline' })
68
+ expect(slider).toBeDisabled()
69
+ const element = loadMedia(30)
70
+ expect(slider).toBeEnabled()
71
+ expect(slider).toHaveAttribute('max', '30')
72
+ act(() => {
73
+ element.currentTime = 12
74
+ fireEvent.timeUpdate(element)
75
+ })
76
+ expect(slider).toHaveAttribute('aria-valuetext', '12 seconds of 30 seconds')
77
+ })
78
+
79
+ it('takes the name it is given', () => {
80
+ renderTimeline({ label: 'Recording position' })
81
+ expect(screen.getByRole('slider', { name: 'Recording position' })).toBeInTheDocument()
82
+ })
83
+
84
+ it('hides markers until a length is known', () => {
85
+ renderTimeline()
86
+ loadMedia(Number.POSITIVE_INFINITY)
87
+ expect(screen.queryByRole('button', { name: 'Frame 2, at 00:04' })).not.toBeInTheDocument()
88
+ })
89
+
90
+ it('scales to the override when the element reports no length', () => {
91
+ renderTimeline({ durationOverride: 30 })
92
+ loadMedia(Number.POSITIVE_INFINITY)
93
+ const slider = screen.getByRole('slider', { name: 'Timeline' })
94
+ expect(slider).toBeEnabled()
95
+ expect(slider).toHaveAttribute('max', '30')
96
+ expect(screen.getByRole('button', { name: 'Frame 2, at 00:04' })).toBeVisible()
97
+ })
98
+
99
+ it('takes the source duration for the whole player when the element has none', () => {
100
+ renderTimeline({}, { blob, duration: 30 })
101
+ loadMedia(Number.POSITIVE_INFINITY)
102
+ expect(screen.getByRole('slider', { name: 'Timeline' })).toHaveAttribute('max', '30')
103
+ expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute('max', '30')
104
+ expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled()
105
+ expect(screen.getByText('00:30')).toBeInTheDocument()
106
+ expect(screen.getByRole('button', { name: 'Frame 2, at 00:04' })).toBeVisible()
107
+ })
108
+
109
+ it('clamps seeking to the known length when the element has none', async () => {
110
+ const user = userEvent.setup()
111
+ renderTimeline({}, { blob, duration: 30 })
112
+ const element = loadMedia(Number.POSITIVE_INFINITY)
113
+ element.currentTime = 25
114
+ await user.click(screen.getByRole('button', { name: 'Forward 10 seconds' }))
115
+ expect(element.currentTime).toBe(30)
116
+ })
117
+
118
+ it('jumps to a marker and names the one holding the playhead', async () => {
119
+ const user = userEvent.setup()
120
+ renderTimeline()
121
+ const element = loadMedia(30)
122
+ expect(screen.getByText('Frames')).toBeInTheDocument()
123
+ await user.click(screen.getByRole('button', { name: 'Frame 2, at 00:04' }))
124
+ expect(element.currentTime).toBe(4)
125
+ expect(screen.getByText('Frame 2')).toBeInTheDocument()
126
+ expect(screen.queryByText('Frame 1')).not.toBeInTheDocument()
127
+ })
128
+
129
+ it('shows no marker track without markers', () => {
130
+ renderTimeline({ markers: [] })
131
+ expect(screen.queryByText('Frames')).not.toBeInTheDocument()
132
+ })
133
+ })
@@ -0,0 +1,78 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants'
2
+
3
+ import { cn } from '../../lib/cn'
4
+ import { Slider } from '../slider/slider'
5
+ import { type MediaMarker, MediaMarkerTrack } from './media-markers'
6
+ import { useMediaPlayer } from './media-player'
7
+ import { spokenMediaTime } from './media-time'
8
+
9
+ /* =========================================================================
10
+ MediaTimeline — the recording as a bare time axis: a seek slider with the
11
+ marker track beneath, for media with no waveform worth drawing (a screen
12
+ recording, a silent clip). Same markers, same seeking as the waveform;
13
+ nothing decoded.
14
+
15
+ `durationOverride` scales the axis for a file whose container reports no
16
+ length, so markers land where they belong while the element still says
17
+ Infinity. `source.duration` on the player does the same for the whole
18
+ player, controls included; the override is for a timeline that must
19
+ disagree with it.
20
+ ========================================================================= */
21
+
22
+ export const mediaTimelineStyles = tv({
23
+ slots: {
24
+ root: 'flex shrink-0 flex-col gap-1 px-[18px] pt-2 pb-3',
25
+ markers: 'pt-1',
26
+ },
27
+ })
28
+
29
+ export type MediaTimelineVariants = VariantProps<typeof mediaTimelineStyles>
30
+
31
+ export interface MediaTimelineProps {
32
+ markers?: ReadonlyArray<MediaMarker>
33
+ /** Heading of the marker track. */
34
+ markersLabel?: string
35
+ /** Spoken name of the seek bar. */
36
+ label?: string
37
+ /** Seconds. Scales the axis in place of the player's duration. */
38
+ durationOverride?: number
39
+ className?: string
40
+ }
41
+
42
+ export function MediaTimeline({
43
+ markers = [],
44
+ markersLabel = 'Markers',
45
+ label = 'Timeline',
46
+ durationOverride,
47
+ className,
48
+ }: MediaTimelineProps) {
49
+ const { currentTime, duration: playerDuration, isReady, seek } = useMediaPlayer()
50
+ const duration = durationOverride ?? playerDuration
51
+ const max = duration > 0 ? duration : 1
52
+ const styles = mediaTimelineStyles()
53
+
54
+ return (
55
+ <div className={cn(styles.root(), className)}>
56
+ <Slider
57
+ aria-label={label}
58
+ value={Math.min(currentTime, max)}
59
+ minValue={0}
60
+ maxValue={max}
61
+ step={1}
62
+ isDisabled={!isReady}
63
+ valueText={`${spokenMediaTime(currentTime)} of ${spokenMediaTime(duration)}`}
64
+ onChange={seek}
65
+ />
66
+ {markers.length > 0 ? (
67
+ <MediaMarkerTrack
68
+ markers={markers}
69
+ label={markersLabel}
70
+ currentTime={currentTime}
71
+ duration={duration}
72
+ onSeek={seek}
73
+ className={styles.markers()}
74
+ />
75
+ ) : null}
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,113 @@
1
+ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+ import { MediaStage, useMediaPlayer } from './media-player'
6
+
7
+ /* =========================================================================
8
+ MediaVideoStage — the player's picture when the recording is video: a
9
+ `<video>` fitted into the stage at its own aspect, with an overlay slot
10
+ that covers exactly the frame. Anything drawn in the overlay in 0..1
11
+ coordinates lands on the pixels it was drawn over, however the stage is
12
+ sized. The overlay ignores the pointer unless its children opt in.
13
+
14
+ The box is fitted by measurement rather than CSS: `aspect-ratio` can't be
15
+ clamped on both axes at once, and `object-fit` would letterbox inside the
16
+ element, leaving the overlay's box wider than the picture.
17
+ ========================================================================= */
18
+
19
+ export const mediaVideoStageStyles = tv({
20
+ slots: {
21
+ root: 'items-center justify-center overflow-hidden',
22
+ box: 'relative shrink-0 bg-ink',
23
+ video: 'block size-full',
24
+ overlay: 'pointer-events-none absolute inset-0',
25
+ },
26
+ })
27
+
28
+ export type MediaVideoStageVariants = VariantProps<typeof mediaVideoStageStyles>
29
+
30
+ /** Until the picture says otherwise. */
31
+ const DEFAULT_ASPECT = 16 / 9
32
+
33
+ /** The largest `aspect` box inside `width` × `height`; width-bound when the height isn't known. */
34
+ export function fitMediaBox(
35
+ width: number,
36
+ height: number,
37
+ aspect: number,
38
+ ): { width: number; height: number } {
39
+ if (width <= 0 || aspect <= 0) return { width: 0, height: 0 }
40
+ if (height <= 0 || width / height <= aspect) return { width, height: width / aspect }
41
+ return { width: height * aspect, height }
42
+ }
43
+
44
+ export interface MediaVideoStageProps {
45
+ /** Drawn over the picture, in a box the size of the frame. */
46
+ children?: ReactNode
47
+ /** Applied to the stage; padding is fine, the box fits the content area. */
48
+ className?: string
49
+ }
50
+
51
+ export function MediaVideoStage({ children, className }: MediaVideoStageProps) {
52
+ const { src, mediaRef } = useMediaPlayer()
53
+ const styles = mediaVideoStageStyles()
54
+ const stageRef = useRef<HTMLDivElement>(null)
55
+ const [video, setVideo] = useState<HTMLVideoElement | null>(null)
56
+ const [aspect, setAspect] = useState(DEFAULT_ASPECT)
57
+ const [box, setBox] = useState({ width: 0, height: 0 })
58
+
59
+ const registerVideo = useCallback(
60
+ (element: HTMLVideoElement | null) => {
61
+ setVideo(element)
62
+ mediaRef(element)
63
+ },
64
+ [mediaRef],
65
+ )
66
+
67
+ useEffect(() => {
68
+ if (!video) return
69
+ const sync = () => {
70
+ if (video.videoWidth > 0 && video.videoHeight > 0)
71
+ setAspect(video.videoWidth / video.videoHeight)
72
+ }
73
+ sync()
74
+ video.addEventListener('loadedmetadata', sync)
75
+ // The picture's own size changed mid-stream.
76
+ video.addEventListener('resize', sync)
77
+ return () => {
78
+ video.removeEventListener('loadedmetadata', sync)
79
+ video.removeEventListener('resize', sync)
80
+ }
81
+ }, [video])
82
+
83
+ // Observing reports the content box straight away, and again on every resize.
84
+ useEffect(() => {
85
+ const stage = stageRef.current
86
+ if (!stage) return
87
+ const observer = new ResizeObserver((entries) => {
88
+ const rect = entries[0]?.contentRect
89
+ if (!rect) return
90
+ const next = fitMediaBox(rect.width, rect.height, aspect)
91
+ setBox((previous) =>
92
+ previous.width === next.width && previous.height === next.height ? previous : next,
93
+ )
94
+ })
95
+ observer.observe(stage)
96
+ return () => observer.disconnect()
97
+ }, [aspect])
98
+
99
+ return (
100
+ <MediaStage ref={stageRef} className={cn(styles.root(), className)}>
101
+ <div className={styles.box()} style={{ width: box.width, height: box.height }}>
102
+ <video
103
+ ref={registerVideo}
104
+ playsInline
105
+ preload="metadata"
106
+ className={styles.video()}
107
+ {...(src ? { src } : {})}
108
+ />
109
+ <div className={styles.overlay()}>{children}</div>
110
+ </div>
111
+ </MediaStage>
112
+ )
113
+ }
@@ -0,0 +1,330 @@
1
+ import { memo, type PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+ import type WaveSurfer from 'wavesurfer.js'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import { type MediaMarker, MediaMarkerTrack } from './media-markers'
7
+ import { type MediaSegment, maskPeaks } from './media-peaks'
8
+ import { useMediaPlayer } from './media-player'
9
+ import { formatMediaTime } from './media-time'
10
+
11
+ /* =========================================================================
12
+ MediaWaveform — the recording drawn as stacked lanes, one per source of
13
+ sound. A lane's bars come from the recording's single curve, masked to the
14
+ lane's segments, so one mixed file can show several people. Without lanes
15
+ it is one unnamed lane holding everything. Markers sit on a track beneath
16
+ (shared with the timeline, see media-markers).
17
+
18
+ Each lane is its own wavesurfer instance on the player's shared media
19
+ element, which keeps labels as ordinary DOM rows between the canvases.
20
+ Pointer seeking and the playhead live here, across all lanes, so the
21
+ instances only draw.
22
+ ========================================================================= */
23
+
24
+ export const mediaWaveformStyles = tv({
25
+ slots: {
26
+ root: 'relative flex min-h-0 flex-1 cursor-pointer touch-none flex-col justify-center gap-2.5 px-[18px] py-3.5',
27
+ lane: 'flex min-h-[34px] max-h-24 flex-1 basis-0 flex-col gap-0.5',
28
+ laneName: 'flex min-w-0 shrink-0 items-center gap-1.5 text-[12px] leading-4 text-ink-3',
29
+ laneDot: 'size-[5px] shrink-0 rounded-pill',
30
+ laneMeta: 'ml-auto shrink-0 font-mono text-[11px] text-ink-4',
31
+ bars: [
32
+ 'relative min-h-0 flex-1',
33
+ "before:absolute before:inset-x-0 before:top-1/2 before:h-px before:bg-rule-2 before:content-['']",
34
+ ],
35
+ canvas: 'absolute inset-0',
36
+ playhead: 'pointer-events-none absolute inset-y-0 z-10 w-px bg-ink',
37
+ hover: 'pointer-events-none absolute inset-y-0 z-10 w-px bg-ink-4',
38
+ chip: [
39
+ 'pointer-events-none absolute top-2 z-20 -translate-x-1/2 whitespace-nowrap rounded-sm bg-ink px-1.5 py-1',
40
+ 'font-mono text-[11px] leading-none text-surface',
41
+ ],
42
+ status: 'pointer-events-none px-[18px] pb-3 text-[12px] text-ink-3',
43
+ markers: 'border-t border-rule-2 pt-2',
44
+ },
45
+ variants: {
46
+ isActive: {
47
+ true: { laneName: 'font-medium text-ink', laneDot: 'bg-ink' },
48
+ },
49
+ isSeekable: {
50
+ false: { root: 'cursor-default' },
51
+ },
52
+ },
53
+ })
54
+
55
+ export type MediaWaveformVariants = VariantProps<typeof mediaWaveformStyles>
56
+
57
+ export interface MediaLane {
58
+ key: string
59
+ /** Null for the one unnamed lane holding the whole recording. */
60
+ label: string | null
61
+ /** Quiet fact at the row's end. */
62
+ meta?: string
63
+ /** Where this lane has sound; null means everywhere. */
64
+ segments: ReadonlyArray<MediaSegment> | null
65
+ }
66
+
67
+ export interface MediaWaveformProps {
68
+ lanes?: ReadonlyArray<MediaLane>
69
+ markers?: ReadonlyArray<MediaMarker>
70
+ /** Heading of the marker track. */
71
+ markersLabel?: string
72
+ className?: string
73
+ }
74
+
75
+ const WHOLE_RECORDING: ReadonlyArray<MediaLane> = [{ key: 'all', label: null, segments: null }]
76
+
77
+ const isWithin = (segments: ReadonlyArray<MediaSegment> | null, time: number) =>
78
+ segments?.some(([start, end]) => time >= start && time < end) ?? false
79
+
80
+ /** Canvas colors can't take a CSS variable, so they follow the theme by hand. */
81
+ function useWaveColors() {
82
+ const [colors, setColors] = useState(() => readColors())
83
+ useEffect(() => {
84
+ const observer = new MutationObserver(() => setColors(readColors()))
85
+ observer.observe(document.documentElement, {
86
+ attributes: true,
87
+ attributeFilter: ['data-theme'],
88
+ })
89
+ return () => observer.disconnect()
90
+ }, [])
91
+ return colors
92
+ }
93
+
94
+ function readColors() {
95
+ const style = getComputedStyle(document.documentElement)
96
+ return {
97
+ wave: style.getPropertyValue('--color-rule-strong').trim() || 'gray',
98
+ progress: style.getPropertyValue('--color-ink').trim() || 'black',
99
+ }
100
+ }
101
+
102
+ const LaneBars = memo(function LaneBars({
103
+ media,
104
+ peaks,
105
+ duration,
106
+ wave,
107
+ progress,
108
+ onDrawFailed,
109
+ }: {
110
+ media: HTMLMediaElement
111
+ peaks: Float32Array
112
+ duration: number
113
+ wave: string
114
+ progress: string
115
+ onDrawFailed: () => void
116
+ }) {
117
+ const containerRef = useRef<HTMLDivElement>(null)
118
+ const instanceRef = useRef<WaveSurfer | null>(null)
119
+ const colorsRef = useRef({ wave, progress })
120
+ colorsRef.current = { wave, progress }
121
+
122
+ useEffect(() => {
123
+ const container = containerRef.current
124
+ if (!container) return
125
+ let isCancelled = false
126
+ let instance: WaveSurfer | null = null
127
+ let observer: ResizeObserver | null = null
128
+ // Loaded on first draw, so conversations without a recording never pay for it.
129
+ import('wavesurfer.js')
130
+ .then(({ default: WaveSurferClass }) => {
131
+ if (isCancelled) return
132
+ instance = WaveSurferClass.create({
133
+ container,
134
+ media,
135
+ peaks: [peaks],
136
+ duration,
137
+ height: container.clientHeight || 1,
138
+ barWidth: 2,
139
+ barGap: 1,
140
+ barRadius: 1,
141
+ cursorWidth: 0,
142
+ interact: false,
143
+ normalize: false,
144
+ autoScroll: false,
145
+ hideScrollbar: true,
146
+ // A new instance otherwise resets the shared element to its own default.
147
+ audioRate: media.playbackRate,
148
+ waveColor: colorsRef.current.wave,
149
+ progressColor: colorsRef.current.progress,
150
+ })
151
+ instanceRef.current = instance
152
+ let lastHeight = container.clientHeight
153
+ observer = new ResizeObserver(() => {
154
+ const height = container.clientHeight
155
+ if (height > 0 && height !== lastHeight) {
156
+ lastHeight = height
157
+ instance?.setOptions({ height })
158
+ }
159
+ })
160
+ observer.observe(container)
161
+ })
162
+ // A chunk that won't load or a draw that throws: the audio still plays.
163
+ .catch(() => {
164
+ if (!isCancelled) onDrawFailed()
165
+ })
166
+ return () => {
167
+ isCancelled = true
168
+ observer?.disconnect()
169
+ instance?.destroy()
170
+ instanceRef.current = null
171
+ }
172
+ }, [media, peaks, duration, onDrawFailed])
173
+
174
+ useEffect(() => {
175
+ instanceRef.current?.setOptions({ waveColor: wave, progressColor: progress })
176
+ }, [wave, progress])
177
+
178
+ return <div ref={containerRef} className={mediaWaveformStyles().canvas()} aria-hidden />
179
+ })
180
+
181
+ export function MediaWaveform({
182
+ lanes: lanesProp,
183
+ markers = [],
184
+ markersLabel = 'Markers',
185
+ className,
186
+ }: MediaWaveformProps) {
187
+ const { media, decoded, duration, currentTime, waveformStatus, isReady, isPlaying, seek } =
188
+ useMediaPlayer()
189
+ const lanes = lanesProp && lanesProp.length > 0 ? lanesProp : WHOLE_RECORDING
190
+ const colors = useWaveColors()
191
+ const styles = mediaWaveformStyles({ isSeekable: isReady })
192
+ const rootRef = useRef<HTMLDivElement>(null)
193
+ const playheadRef = useRef<HTMLDivElement>(null)
194
+ const [hover, setHover] = useState<{ x: number; text: string } | null>(null)
195
+ const [isDrawFailed, setIsDrawFailed] = useState(false)
196
+ const handleDrawFailed = useCallback(() => setIsDrawFailed(true), [])
197
+
198
+ const lanePeaks = useMemo(
199
+ () =>
200
+ lanes.map((lane) =>
201
+ decoded && lane.segments
202
+ ? maskPeaks(decoded.peaks, decoded.duration, lane.segments)
203
+ : (decoded?.peaks ?? null),
204
+ ),
205
+ [lanes, decoded],
206
+ )
207
+
208
+ // The playhead moves every frame while playing, outside React's render.
209
+ useEffect(() => {
210
+ const playhead = playheadRef.current
211
+ if (!media || !playhead) return
212
+ let frame = 0
213
+ const place = () => {
214
+ const position = duration > 0 ? Math.min(1, media.currentTime / duration) : 0
215
+ playhead.style.left = `calc(18px + (100% - 36px) * ${position})`
216
+ playhead.style.visibility = position > 0 ? 'visible' : 'hidden'
217
+ }
218
+ const loop = () => {
219
+ place()
220
+ frame = requestAnimationFrame(loop)
221
+ }
222
+ place()
223
+ if (isPlaying) frame = requestAnimationFrame(loop)
224
+ media.addEventListener('seeked', place)
225
+ return () => {
226
+ cancelAnimationFrame(frame)
227
+ media.removeEventListener('seeked', place)
228
+ }
229
+ }, [media, duration, isPlaying])
230
+
231
+ const timeAt = (clientX: number) => {
232
+ const rect = rootRef.current?.getBoundingClientRect()
233
+ if (!rect || duration <= 0) return 0
234
+ return (
235
+ (Math.max(0, Math.min(1, (clientX - rect.left - 18) / (rect.width - 36))) || 0) * duration
236
+ )
237
+ }
238
+
239
+ const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
240
+ if (!isReady || (event.target as HTMLElement).closest('[data-media-marker]')) return
241
+ event.currentTarget.setPointerCapture(event.pointerId)
242
+ seek(timeAt(event.clientX))
243
+ }
244
+
245
+ const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
246
+ if (!isReady) return
247
+ const time = timeAt(event.clientX)
248
+ if (event.buttons > 0 && event.currentTarget.hasPointerCapture(event.pointerId)) seek(time)
249
+ const target = event.target as HTMLElement
250
+ const markerKey = target.closest<HTMLElement>('[data-media-marker]')?.dataset.mediaMarker
251
+ const laneKey = target.closest<HTMLElement>('[data-media-lane]')?.dataset.mediaLane
252
+ const marker = markers.find((entry) => entry.key === markerKey)
253
+ const lane = lanes.find((entry) => entry.key === laneKey)
254
+ const name = marker?.label ?? (lane?.label && isWithin(lane.segments, time) ? lane.label : null)
255
+ const rect = event.currentTarget.getBoundingClientRect()
256
+ setHover({
257
+ x: event.clientX - rect.left,
258
+ text: name ? `${name} · ${formatMediaTime(time)}` : formatMediaTime(time),
259
+ })
260
+ }
261
+
262
+ return (
263
+ <>
264
+ <div
265
+ ref={rootRef}
266
+ onPointerDown={handlePointerDown}
267
+ onPointerMove={handlePointerMove}
268
+ onPointerLeave={() => setHover(null)}
269
+ className={cn(styles.root(), className)}
270
+ >
271
+ {lanes.map((lane, index) => {
272
+ const isActive = lane.segments !== null && isWithin(lane.segments, currentTime)
273
+ const peaks = lanePeaks[index]
274
+ const laneStyles = mediaWaveformStyles({ isActive })
275
+ return (
276
+ <div key={lane.key} data-media-lane={lane.key} className={styles.lane()}>
277
+ {lane.label !== null ? (
278
+ <div className={laneStyles.laneName()} data-active={isActive || undefined}>
279
+ <span aria-hidden className={laneStyles.laneDot()} />
280
+ <span className="min-w-0 truncate">{lane.label}</span>
281
+ {lane.meta ? <span className={styles.laneMeta()}>{lane.meta}</span> : null}
282
+ </div>
283
+ ) : null}
284
+ <div className={styles.bars()}>
285
+ {media && peaks && decoded ? (
286
+ <LaneBars
287
+ media={media}
288
+ peaks={peaks}
289
+ duration={decoded.duration}
290
+ wave={colors.wave}
291
+ progress={colors.progress}
292
+ onDrawFailed={handleDrawFailed}
293
+ />
294
+ ) : null}
295
+ </div>
296
+ </div>
297
+ )
298
+ })}
299
+
300
+ {markers.length > 0 ? (
301
+ <MediaMarkerTrack
302
+ markers={markers}
303
+ label={markersLabel}
304
+ currentTime={currentTime}
305
+ duration={duration}
306
+ onSeek={seek}
307
+ className={styles.markers()}
308
+ />
309
+ ) : null}
310
+
311
+ <div ref={playheadRef} className={styles.playhead()} />
312
+ {hover ? (
313
+ <>
314
+ <div className={styles.hover()} style={{ left: hover.x }} />
315
+ <span
316
+ className={styles.chip()}
317
+ style={{ left: `clamp(70px, ${hover.x}px, calc(100% - 70px))` }}
318
+ >
319
+ {hover.text}
320
+ </span>
321
+ </>
322
+ ) : null}
323
+ </div>
324
+ {waveformStatus === 'decoding' ? <p className={styles.status()}>Drawing waveform</p> : null}
325
+ {waveformStatus === 'unavailable' || isDrawFailed ? (
326
+ <p className={styles.status()}>Waveform isn’t available. Use the slider to seek.</p>
327
+ ) : null}
328
+ </>
329
+ )
330
+ }
@@ -0,0 +1,2 @@
1
+ export type { SliderProps, SliderVariants } from './slider'
2
+ export { Slider, sliderStyles } from './slider'
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Slider } from './slider'
5
+
6
+ const meta: Meta<typeof Slider> = {
7
+ title: 'Components/Slider',
8
+ component: Slider,
9
+ tags: ['autodocs'],
10
+ }
11
+
12
+ export default meta
13
+ type Story = StoryObj<typeof Slider>
14
+
15
+ function Controlled() {
16
+ const [value, setValue] = useState(40)
17
+ return (
18
+ <div className="w-[320px]">
19
+ <Slider aria-label="Position" value={value} maxValue={100} onChange={setValue} />
20
+ </div>
21
+ )
22
+ }
23
+
24
+ export const Default: Story = { render: () => <Controlled /> }
25
+
26
+ export const Disabled: Story = {
27
+ render: () => (
28
+ <div className="w-[320px]">
29
+ <Slider aria-label="Position" value={40} maxValue={100} isDisabled />
30
+ </div>
31
+ ),
32
+ }