@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,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,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
|
+
}
|