@proteos/ui 0.76.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,318 @@
1
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import type { ReactNode } from 'react'
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import { MediaControls } from './media-controls'
7
+ import { decodePeaks } from './media-peaks'
8
+ import { MediaPlayer, MediaStage, useMediaPlayer } from './media-player'
9
+ import { fitMediaBox, MediaVideoStage } from './media-video-stage'
10
+ import { MediaWaveform } from './media-waveform'
11
+
12
+ vi.mock('wavesurfer.js', () => ({
13
+ default: { create: vi.fn(() => ({ destroy: vi.fn(), setOptions: vi.fn() })) },
14
+ }))
15
+ vi.mock('./media-peaks', async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import('./media-peaks')>()),
17
+ decodePeaks: vi.fn(async () => ({ peaks: new Float32Array([1, 1, 1, 1]), duration: 120 })),
18
+ }))
19
+
20
+ const blob = new Blob(['audio'], { type: 'audio/mpeg' })
21
+
22
+ function audioElement(): HTMLAudioElement {
23
+ const element = document.querySelector('audio')
24
+ if (!element) throw new Error('no audio element')
25
+ return element
26
+ }
27
+
28
+ function videoElement(): HTMLVideoElement {
29
+ const element = document.querySelector('video')
30
+ if (!element) throw new Error('no video element')
31
+ return element
32
+ }
33
+
34
+ /** jsdom has no media pipeline: stand in for what a loaded file reports. */
35
+ function loadMedia(duration = 120, element: HTMLMediaElement = audioElement()) {
36
+ Object.defineProperty(element, 'duration', { configurable: true, value: duration })
37
+ let paused = true
38
+ Object.defineProperty(element, 'paused', { configurable: true, get: () => paused })
39
+ element.play = vi.fn(async () => {
40
+ paused = false
41
+ fireEvent.play(element)
42
+ })
43
+ element.pause = vi.fn(() => {
44
+ paused = true
45
+ fireEvent.pause(element)
46
+ })
47
+ fireEvent.loadedMetadata(element)
48
+ return element
49
+ }
50
+
51
+ function renderPlayer(props: Partial<Parameters<typeof MediaWaveform>[0]> = {}) {
52
+ return render(
53
+ <MediaPlayer source={{ blob }} label="Call recording">
54
+ <MediaStage>
55
+ <MediaWaveform {...props} />
56
+ </MediaStage>
57
+ <MediaControls />
58
+ </MediaPlayer>,
59
+ )
60
+ }
61
+
62
+ beforeEach(() => {
63
+ URL.createObjectURL = vi.fn(() => 'blob:recording')
64
+ URL.revokeObjectURL = vi.fn()
65
+ global.ResizeObserver = class {
66
+ observe() {}
67
+ disconnect() {}
68
+ unobserve() {}
69
+ } as unknown as typeof ResizeObserver
70
+ })
71
+
72
+ describe('MediaPlayer', () => {
73
+ it('names its region and plays the blob locally', () => {
74
+ renderPlayer()
75
+ expect(screen.getByRole('region', { name: 'Call recording' })).toBeInTheDocument()
76
+ expect(audioElement()).toHaveAttribute('src', 'blob:recording')
77
+ })
78
+
79
+ it('keeps controls disabled until the media loads', () => {
80
+ renderPlayer()
81
+ expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled()
82
+ loadMedia()
83
+ expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled()
84
+ expect(screen.getByText('02:00')).toBeInTheDocument()
85
+ })
86
+
87
+ it('toggles between play and pause', async () => {
88
+ const user = userEvent.setup()
89
+ renderPlayer()
90
+ const element = loadMedia()
91
+ await user.click(screen.getByRole('button', { name: 'Play' }))
92
+ expect(element.play).toHaveBeenCalled()
93
+ await user.click(await screen.findByRole('button', { name: 'Pause' }))
94
+ expect(element.pause).toHaveBeenCalled()
95
+ })
96
+
97
+ it('stop pauses and returns to the start', async () => {
98
+ const user = userEvent.setup()
99
+ renderPlayer()
100
+ const element = loadMedia()
101
+ element.currentTime = 50
102
+ await user.click(screen.getByRole('button', { name: 'Stop' }))
103
+ expect(element.pause).toHaveBeenCalled()
104
+ expect(element.currentTime).toBe(0)
105
+ })
106
+
107
+ it('skips ten seconds, clamped to the recording', async () => {
108
+ const user = userEvent.setup()
109
+ renderPlayer()
110
+ const element = loadMedia(120)
111
+ element.currentTime = 115
112
+ await user.click(screen.getByRole('button', { name: 'Forward 10 seconds' }))
113
+ expect(element.currentTime).toBe(120)
114
+ element.currentTime = 5
115
+ await user.click(screen.getByRole('button', { name: 'Back 10 seconds' }))
116
+ expect(element.currentTime).toBe(0)
117
+ })
118
+
119
+ it('cycles the playback speed', async () => {
120
+ const user = userEvent.setup()
121
+ renderPlayer()
122
+ const element = loadMedia()
123
+ await user.click(screen.getByRole('button', { name: 'Playback speed 1 times' }))
124
+ expect(element.playbackRate).toBe(1.25)
125
+ expect(screen.getByRole('button', { name: 'Playback speed 1.25 times' })).toBeInTheDocument()
126
+ })
127
+
128
+ it('reports an unplayable file', () => {
129
+ const onError = vi.fn()
130
+ render(
131
+ <MediaPlayer source={{ blob }} label="Call recording" onError={onError}>
132
+ <MediaControls />
133
+ </MediaPlayer>,
134
+ )
135
+ fireEvent.error(audioElement())
136
+ expect(onError).toHaveBeenCalled()
137
+ })
138
+
139
+ it('releases the blob and stops when unmounted', () => {
140
+ const { unmount } = renderPlayer()
141
+ const element = loadMedia()
142
+ unmount()
143
+ expect(element.pause).toHaveBeenCalled()
144
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:recording')
145
+ })
146
+
147
+ it('announces the position through the slider', () => {
148
+ renderPlayer()
149
+ const element = loadMedia(120)
150
+ act(() => {
151
+ element.currentTime = 48
152
+ fireEvent.timeUpdate(element)
153
+ })
154
+ expect(screen.getByRole('slider', { name: 'Seek' })).toHaveAttribute(
155
+ 'aria-valuetext',
156
+ '48 seconds of 2 minutes',
157
+ )
158
+ })
159
+
160
+ it('keeps the transport without its seek row when another scrubber owns seeking', () => {
161
+ render(
162
+ <MediaPlayer source={{ blob }} label="Call recording">
163
+ <MediaControls hasSeek={false} />
164
+ </MediaPlayer>,
165
+ )
166
+ loadMedia()
167
+ expect(screen.queryByRole('slider', { name: 'Seek' })).not.toBeInTheDocument()
168
+ expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
169
+ })
170
+
171
+ it('streams a url and draws the peaks it was given', () => {
172
+ render(
173
+ <MediaPlayer
174
+ source={{ url: 'https://example.test/call.mp3', peaks: new Float32Array(4), duration: 90 }}
175
+ label="Call recording"
176
+ >
177
+ <MediaControls />
178
+ </MediaPlayer>,
179
+ )
180
+ expect(audioElement()).toHaveAttribute('src', 'https://example.test/call.mp3')
181
+ expect(URL.createObjectURL).not.toHaveBeenCalled()
182
+ })
183
+ })
184
+
185
+ describe('MediaWaveform', () => {
186
+ it('says so when the waveform library fails, and keeps playback', async () => {
187
+ const { default: WaveSurfer } = await import('wavesurfer.js')
188
+ vi.mocked(WaveSurfer.create).mockImplementationOnce(() => {
189
+ throw new Error('chunk failed')
190
+ })
191
+ renderPlayer()
192
+ loadMedia()
193
+ expect(await screen.findByText(/Waveform isn’t available/)).toBeInTheDocument()
194
+ expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled()
195
+ })
196
+
197
+ const lanes = [
198
+ { key: 'a', label: 'Dana', meta: '00:40', segments: [[0, 10]] as const },
199
+ { key: 'b', label: 'You', meta: '00:20', segments: [[10, 20]] as const },
200
+ ]
201
+
202
+ it('names each lane with its fact', () => {
203
+ renderPlayer({ lanes })
204
+ expect(screen.getByText('Dana')).toBeInTheDocument()
205
+ expect(screen.getByText('00:40')).toBeInTheDocument()
206
+ expect(screen.getByText('You')).toBeInTheDocument()
207
+ })
208
+
209
+ it('marks whoever is speaking at the current time', () => {
210
+ renderPlayer({ lanes })
211
+ const element = loadMedia()
212
+ act(() => {
213
+ element.currentTime = 12
214
+ fireEvent.timeUpdate(element)
215
+ })
216
+ expect(screen.getByText('You').parentElement).toHaveAttribute('data-active', 'true')
217
+ expect(screen.getByText('Dana').parentElement).not.toHaveAttribute('data-active')
218
+ })
219
+
220
+ it('shows no names for the one unnamed lane', () => {
221
+ renderPlayer()
222
+ expect(screen.queryByText('Dana')).not.toBeInTheDocument()
223
+ })
224
+
225
+ it('reports while the waveform is being drawn', async () => {
226
+ renderPlayer()
227
+ expect(screen.getByText('Drawing waveform')).toBeInTheDocument()
228
+ await waitFor(() => expect(screen.queryByText('Drawing waveform')).not.toBeInTheDocument())
229
+ })
230
+
231
+ it('jumps to a marker', async () => {
232
+ const user = userEvent.setup()
233
+ renderPlayer({
234
+ markers: [{ key: 'tag', label: 'Pricing', start: 30, end: 40 }],
235
+ markersLabel: 'Tags',
236
+ })
237
+ const element = loadMedia()
238
+ await user.click(screen.getByRole('button', { name: 'Pricing, at 00:30' }))
239
+ expect(element.currentTime).toBe(30)
240
+ expect(screen.getByText('Tags')).toBeInTheDocument()
241
+ })
242
+ })
243
+
244
+ describe('MediaVideoStage', () => {
245
+ const recording = new Blob(['video'], { type: 'video/webm' })
246
+
247
+ function renderVideo(overlay?: ReactNode, probe?: ReactNode) {
248
+ return render(
249
+ <MediaPlayer source={{ blob: recording, duration: 8 }} label="Screen recording" kind="video">
250
+ <MediaVideoStage>{overlay}</MediaVideoStage>
251
+ {probe}
252
+ <MediaControls />
253
+ </MediaPlayer>,
254
+ )
255
+ }
256
+
257
+ it('plays through an inline video element instead of audio', () => {
258
+ renderVideo()
259
+ const video = videoElement()
260
+ expect(video).toHaveAttribute('src', 'blob:recording')
261
+ expect(video).toHaveAttribute('playsinline')
262
+ expect(document.querySelector('audio')).toBeNull()
263
+ expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled()
264
+ // A MediaRecorder webm reports no length; the source's duration stands in.
265
+ loadMedia(Number.POSITIVE_INFINITY, video)
266
+ expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled()
267
+ expect(screen.getByText('00:08')).toBeInTheDocument()
268
+ })
269
+
270
+ it('drives the video from the controls', async () => {
271
+ const user = userEvent.setup()
272
+ renderVideo()
273
+ const video = loadMedia(Number.POSITIVE_INFINITY, videoElement())
274
+ await user.click(screen.getByRole('button', { name: 'Play' }))
275
+ expect(video.play).toHaveBeenCalled()
276
+ video.currentTime = 5
277
+ await user.click(screen.getByRole('button', { name: 'Forward 10 seconds' }))
278
+ expect(video.currentTime).toBe(8)
279
+ })
280
+
281
+ it('draws the overlay in the same box as the picture', () => {
282
+ renderVideo(<span>ink</span>)
283
+ const overlay = screen.getByText('ink').parentElement
284
+ expect(overlay).not.toBeNull()
285
+ expect(overlay?.parentElement).toBe(videoElement().parentElement)
286
+ })
287
+
288
+ it('plays on without a waveform when the bytes hold no audio', async () => {
289
+ vi.mocked(decodePeaks).mockRejectedValueOnce(new Error('no audio track'))
290
+ const statuses: string[] = []
291
+ function Probe() {
292
+ statuses.push(useMediaPlayer().waveformStatus)
293
+ return null
294
+ }
295
+ renderVideo(undefined, <Probe />)
296
+ await waitFor(() => expect(statuses.at(-1)).toBe('unavailable'))
297
+ loadMedia(Number.POSITIVE_INFINITY, videoElement())
298
+ expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled()
299
+ })
300
+ })
301
+
302
+ describe('fitMediaBox', () => {
303
+ it('is bound by height in a wide stage', () => {
304
+ expect(fitMediaBox(1000, 300, 2)).toEqual({ width: 600, height: 300 })
305
+ })
306
+
307
+ it('is bound by width in a tall stage', () => {
308
+ expect(fitMediaBox(400, 1000, 2)).toEqual({ width: 400, height: 200 })
309
+ })
310
+
311
+ it('is bound by width when the stage has no height yet', () => {
312
+ expect(fitMediaBox(400, 0, 2)).toEqual({ width: 400, height: 200 })
313
+ })
314
+
315
+ it('is empty without a width', () => {
316
+ expect(fitMediaBox(0, 300, 2)).toEqual({ width: 0, height: 0 })
317
+ })
318
+ })
@@ -0,0 +1,334 @@
1
+ import {
2
+ createContext,
3
+ type KeyboardEvent,
4
+ type ReactNode,
5
+ type Ref,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ } from 'react'
12
+ import { tv, type VariantProps } from 'tailwind-variants'
13
+
14
+ import { cn } from '../../lib/cn'
15
+ import { type DecodedPeaks, decodePeaks, MAX_DECODE_BYTES } from './media-peaks'
16
+
17
+ /* =========================================================================
18
+ MediaPlayer — chrome-free root of a recording player: owns the media
19
+ element's state; slots compose beneath it.
20
+
21
+ <MediaPlayer source={{ blob }} label="Call recording">
22
+ <MediaStage><MediaWaveform lanes={lanes} markers={markers} /></MediaStage>
23
+ <MediaControls />
24
+ </MediaPlayer>
25
+
26
+ <MediaPlayer source={{ blob, duration }} label="Screen recording" kind="video">
27
+ <MediaVideoStage>{overlay}</MediaVideoStage>
28
+ <MediaTimeline markers={markers} />
29
+ <MediaControls />
30
+ </MediaPlayer>
31
+
32
+ The source is a `blob` (whole file local, bars decoded in the browser) or a
33
+ `url` (bytes streamed; bars only with pre-computed peaks). `duration` is
34
+ the consumer's authoritative length for files whose container reports
35
+ none — a MediaRecorder webm says Infinity until it has been played through.
36
+ The player knows nothing about how the bytes were authorized.
37
+
38
+ `kind` names the element. `audio` (the default) is rendered here, unseen.
39
+ `video` is rendered by a `MediaVideoStage` child through `mediaRef`, so the
40
+ picture sits wherever the consumer lays it out.
41
+ ========================================================================= */
42
+
43
+ export const mediaPlayerStyles = tv({
44
+ slots: {
45
+ root: 'flex min-h-0 flex-1 flex-col',
46
+ stage: 'relative flex min-h-0 flex-1 flex-col',
47
+ },
48
+ })
49
+
50
+ export type MediaPlayerVariants = VariantProps<typeof mediaPlayerStyles>
51
+
52
+ export type MediaPlayerSource =
53
+ | { blob: Blob; duration?: number }
54
+ | { url: string; peaks?: Float32Array; duration?: number }
55
+
56
+ export type MediaPlayerKind = 'audio' | 'video'
57
+
58
+ /** Whether bars can be drawn yet. `unavailable`: playable, but no waveform. */
59
+ export type MediaWaveformStatus = 'decoding' | 'ready' | 'unavailable'
60
+
61
+ export interface MediaPlayerContextValue {
62
+ media: HTMLMediaElement | null
63
+ /** Callback ref for the element that plays. `MediaVideoStage` hands it its `<video>`. */
64
+ mediaRef: (element: HTMLMediaElement | null) => void
65
+ /** What the element loads; null until a blob has its object URL. */
66
+ src: string | null
67
+ isReady: boolean
68
+ isPlaying: boolean
69
+ currentTime: number
70
+ duration: number
71
+ playbackRate: number
72
+ waveformStatus: MediaWaveformStatus
73
+ decoded: DecodedPeaks | null
74
+ toggle: () => void
75
+ stop: () => void
76
+ seek: (seconds: number) => void
77
+ skip: (seconds: number) => void
78
+ setPlaybackRate: (rate: number) => void
79
+ }
80
+
81
+ const MediaPlayerContext = createContext<MediaPlayerContextValue | null>(null)
82
+
83
+ export function useMediaPlayer(): MediaPlayerContextValue {
84
+ const context = useContext(MediaPlayerContext)
85
+ if (!context) throw new Error('useMediaPlayer must be used inside a MediaPlayer')
86
+ return context
87
+ }
88
+
89
+ const HAVE_METADATA = 1
90
+
91
+ export interface MediaPlayerProps {
92
+ source: MediaPlayerSource
93
+ /** Names the player's region for assistive tech. */
94
+ label: string
95
+ /** Which element plays; `video` expects a `MediaVideoStage` among the children. */
96
+ kind?: MediaPlayerKind
97
+ /** The element can't play these bytes. */
98
+ onError?: () => void
99
+ className?: string
100
+ children: ReactNode
101
+ }
102
+
103
+ export function MediaPlayer({
104
+ source,
105
+ label,
106
+ kind = 'audio',
107
+ onError,
108
+ className,
109
+ children,
110
+ }: MediaPlayerProps) {
111
+ const [media, setMedia] = useState<HTMLMediaElement | null>(null)
112
+ const [src, setSrc] = useState<string | null>(null)
113
+ // The element whose metadata has loaded; a replacement element starts over.
114
+ const [loaded, setLoaded] = useState<HTMLMediaElement | null>(null)
115
+ const [isPlaying, setIsPlaying] = useState(false)
116
+ const [currentTime, setCurrentTime] = useState(0)
117
+ const [mediaDuration, setMediaDuration] = useState(0)
118
+ const [playbackRate, setPlaybackRateState] = useState(1)
119
+ const [waveformStatus, setWaveformStatus] = useState<MediaWaveformStatus>('decoding')
120
+ const [decoded, setDecoded] = useState<DecodedPeaks | null>(null)
121
+
122
+ const blob = 'blob' in source ? source.blob : null
123
+ const url = 'url' in source ? source.url : null
124
+ const givenPeaks = 'url' in source ? (source.peaks ?? null) : null
125
+ const givenDuration = source.duration ?? null
126
+
127
+ useEffect(() => {
128
+ if (!blob) {
129
+ setSrc(url)
130
+ return
131
+ }
132
+ const objectUrl = URL.createObjectURL(blob)
133
+ setSrc(objectUrl)
134
+ return () => URL.revokeObjectURL(objectUrl)
135
+ }, [blob, url])
136
+
137
+ useEffect(() => {
138
+ if (!blob) {
139
+ if (givenPeaks && givenDuration !== null) {
140
+ setDecoded({ peaks: givenPeaks, duration: givenDuration })
141
+ setWaveformStatus('ready')
142
+ } else {
143
+ // Streamed bytes can't be decoded here; without peaks there are no bars.
144
+ setDecoded(null)
145
+ setWaveformStatus('unavailable')
146
+ }
147
+ return
148
+ }
149
+ setDecoded(null)
150
+ if (blob.size > MAX_DECODE_BYTES) {
151
+ setWaveformStatus('unavailable')
152
+ return
153
+ }
154
+ setWaveformStatus('decoding')
155
+ let isCancelled = false
156
+ decodePeaks(blob)
157
+ .then((result) => {
158
+ if (isCancelled) return
159
+ setDecoded(result)
160
+ setWaveformStatus('ready')
161
+ })
162
+ .catch(() => {
163
+ // Bars are a convenience: bytes the decoder rejects (video without an
164
+ // audio track, a format it doesn't know) may still play.
165
+ if (!isCancelled) setWaveformStatus('unavailable')
166
+ })
167
+ return () => {
168
+ isCancelled = true
169
+ }
170
+ }, [blob, givenPeaks, givenDuration])
171
+
172
+ useEffect(() => {
173
+ if (!media) return
174
+ const syncTime = () => setCurrentTime(media.currentTime)
175
+ const syncDuration = () =>
176
+ setMediaDuration(Number.isFinite(media.duration) ? media.duration : 0)
177
+ const syncLoaded = () => {
178
+ setLoaded(media)
179
+ syncDuration()
180
+ }
181
+ const syncEmptied = () => {
182
+ setLoaded(null)
183
+ setMediaDuration(0)
184
+ setCurrentTime(0)
185
+ }
186
+ const syncPlaying = () => setIsPlaying(!media.paused && !media.ended)
187
+ const handleError = () => onError?.()
188
+ // A cached source may have loaded before this ran.
189
+ if (media.readyState >= HAVE_METADATA) syncLoaded()
190
+ const events: Array<[string, () => void]> = [
191
+ ['timeupdate', syncTime],
192
+ ['seeked', syncTime],
193
+ ['loadedmetadata', syncLoaded],
194
+ ['durationchange', syncDuration],
195
+ ['emptied', syncEmptied],
196
+ ['play', syncPlaying],
197
+ ['pause', syncPlaying],
198
+ ['ended', syncPlaying],
199
+ ['error', handleError],
200
+ ]
201
+ for (const [name, handler] of events) media.addEventListener(name, handler)
202
+ return () => {
203
+ for (const [name, handler] of events) media.removeEventListener(name, handler)
204
+ media.pause()
205
+ }
206
+ }, [media, onError])
207
+
208
+ // The element's own length when it has one; the consumer's word otherwise;
209
+ // what the decoder heard as a last resort.
210
+ const duration = mediaDuration || givenDuration || decoded?.duration || 0
211
+ const isReady = media !== null && loaded === media
212
+
213
+ const toggle = useCallback(() => {
214
+ if (!media) return
215
+ if (media.paused || media.ended) {
216
+ // A rejection here is a pause racing the play, or an unplayable file,
217
+ // which the element's own `error` event reports.
218
+ void media.play().catch(() => undefined)
219
+ } else {
220
+ media.pause()
221
+ }
222
+ }, [media])
223
+
224
+ const seek = useCallback(
225
+ (seconds: number) => {
226
+ if (!media) return
227
+ const limit = Number.isFinite(media.duration)
228
+ ? media.duration
229
+ : duration > 0
230
+ ? duration
231
+ : seconds
232
+ const next = Math.max(0, Math.min(limit, seconds))
233
+ media.currentTime = next
234
+ setCurrentTime(next)
235
+ },
236
+ [media, duration],
237
+ )
238
+
239
+ const skip = useCallback(
240
+ (seconds: number) => {
241
+ if (media) seek(media.currentTime + seconds)
242
+ },
243
+ [media, seek],
244
+ )
245
+
246
+ const stop = useCallback(() => {
247
+ media?.pause()
248
+ seek(0)
249
+ }, [media, seek])
250
+
251
+ const setPlaybackRate = useCallback(
252
+ (rate: number) => {
253
+ if (media) media.playbackRate = rate
254
+ setPlaybackRateState(rate)
255
+ },
256
+ [media],
257
+ )
258
+
259
+ const value = useMemo<MediaPlayerContextValue>(
260
+ () => ({
261
+ media,
262
+ mediaRef: setMedia,
263
+ src,
264
+ isReady,
265
+ isPlaying,
266
+ currentTime,
267
+ duration,
268
+ playbackRate,
269
+ waveformStatus,
270
+ decoded,
271
+ toggle,
272
+ stop,
273
+ seek,
274
+ skip,
275
+ setPlaybackRate,
276
+ }),
277
+ [
278
+ media,
279
+ src,
280
+ isReady,
281
+ isPlaying,
282
+ currentTime,
283
+ duration,
284
+ playbackRate,
285
+ waveformStatus,
286
+ decoded,
287
+ toggle,
288
+ stop,
289
+ seek,
290
+ skip,
291
+ setPlaybackRate,
292
+ ],
293
+ )
294
+
295
+ // Space plays from anywhere in the player; buttons and the slider keep theirs.
296
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
297
+ if (event.code !== 'Space') return
298
+ if ((event.target as HTMLElement).closest('button, input')) return
299
+ event.preventDefault()
300
+ toggle()
301
+ }
302
+
303
+ return (
304
+ <MediaPlayerContext.Provider value={value}>
305
+ <section
306
+ aria-label={label}
307
+ onKeyDown={handleKeyDown}
308
+ className={cn(mediaPlayerStyles().root(), className)}
309
+ >
310
+ {kind === 'audio' ? (
311
+ <audio ref={setMedia} preload="metadata" {...(src ? { src } : {})} />
312
+ ) : null}
313
+ {children}
314
+ </section>
315
+ </MediaPlayerContext.Provider>
316
+ )
317
+ }
318
+
319
+ /** Where the picture goes: waveform lanes, or a `MediaVideoStage`. */
320
+ export function MediaStage({
321
+ ref,
322
+ className,
323
+ children,
324
+ }: {
325
+ ref?: Ref<HTMLDivElement>
326
+ className?: string
327
+ children: ReactNode
328
+ }) {
329
+ return (
330
+ <div ref={ref} className={cn(mediaPlayerStyles().stage(), className)}>
331
+ {children}
332
+ </div>
333
+ )
334
+ }
@@ -0,0 +1,27 @@
1
+ const pad = (value: number) => String(value).padStart(2, '0')
2
+
3
+ /** `02:36`, or `1:02:36` past the hour. */
4
+ export function formatMediaTime(seconds: number): string {
5
+ const total = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0))
6
+ const hours = Math.floor(total / 3600)
7
+ const minutes = Math.floor((total % 3600) / 60)
8
+ const rest = total % 60
9
+ return hours > 0 ? `${hours}:${pad(minutes)}:${pad(rest)}` : `${pad(minutes)}:${pad(rest)}`
10
+ }
11
+
12
+ const unit = (value: number, name: string) => `${value} ${name}${value === 1 ? '' : 's'}`
13
+
14
+ /** `2 minutes 36 seconds`, for screen readers. */
15
+ export function spokenMediaTime(seconds: number): string {
16
+ const total = Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0))
17
+ const hours = Math.floor(total / 3600)
18
+ const minutes = Math.floor((total % 3600) / 60)
19
+ const rest = total % 60
20
+ return [
21
+ hours > 0 ? unit(hours, 'hour') : '',
22
+ minutes > 0 ? unit(minutes, 'minute') : '',
23
+ rest > 0 || total === 0 ? unit(rest, 'second') : '',
24
+ ]
25
+ .filter(Boolean)
26
+ .join(' ')
27
+ }