@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.
- package/dist/index.cjs +991 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +592 -2
- package/dist/index.d.ts +592 -2
- package/dist/index.js +969 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/data-table/data-table.scroll-restore.test.tsx +97 -0
- package/src/components/data-table/data-table.test.tsx +22 -2
- package/src/components/data-table/data-table.tsx +31 -1
- package/src/components/frame-strip/frame-strip.stories.tsx +61 -0
- package/src/components/frame-strip/frame-strip.test.tsx +112 -0
- package/src/components/frame-strip/frame-strip.tsx +131 -0
- package/src/components/frame-strip/index.ts +2 -0
- package/src/components/media-player/index.ts +22 -0
- package/src/components/media-player/media-controls.tsx +145 -0
- package/src/components/media-player/media-markers.tsx +108 -0
- package/src/components/media-player/media-peaks.test.ts +68 -0
- package/src/components/media-player/media-peaks.ts +78 -0
- package/src/components/media-player/media-player.stories.tsx +243 -0
- package/src/components/media-player/media-player.test.tsx +318 -0
- package/src/components/media-player/media-player.tsx +334 -0
- package/src/components/media-player/media-time.ts +27 -0
- package/src/components/media-player/media-timeline.test.tsx +133 -0
- package/src/components/media-player/media-timeline.tsx +78 -0
- package/src/components/media-player/media-video-stage.tsx +113 -0
- package/src/components/media-player/media-waveform.tsx +330 -0
- package/src/components/slider/index.ts +2 -0
- package/src/components/slider/slider.stories.tsx +32 -0
- package/src/components/slider/slider.test.tsx +35 -0
- package/src/components/slider/slider.tsx +89 -0
- 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
|
+
}
|