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