@proteos/ui 0.77.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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"description": "Proteos React component library (React Aria Components + Tailwind v4).",
|
|
6
6
|
"repository": {
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
58
|
"thinking-orbs": "^0.3.1",
|
|
59
|
-
"
|
|
59
|
+
"wavesurfer.js": "8.0.5",
|
|
60
|
+
"@proteos/sdk": "0.78.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { DataTable, type InfiniteQueryLike } from './data-table'
|
|
5
|
+
|
|
6
|
+
// Deliberately NOT mocking @tanstack/react-virtual: when the virtualizer reads
|
|
7
|
+
// its seed, and what it does while disabled, is exactly what these tests pin.
|
|
8
|
+
// jsdom has no layout, so only the scroller's geometry is stubbed.
|
|
9
|
+
|
|
10
|
+
type Row = { id: string; name: string }
|
|
11
|
+
|
|
12
|
+
const VIEWPORT_HEIGHT = 600
|
|
13
|
+
const ROW_HEIGHT = 32
|
|
14
|
+
const columns = [{ id: 'name', accessorKey: 'name', header: 'Name', size: 160 }]
|
|
15
|
+
|
|
16
|
+
function rows(count: number): Row[] {
|
|
17
|
+
return Array.from({ length: count }, (_unused, index) => ({
|
|
18
|
+
id: `r-${index}`,
|
|
19
|
+
name: `Row ${index}`,
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function query(data: Row[], isLoading = false): InfiniteQueryLike<Row> {
|
|
24
|
+
return {
|
|
25
|
+
data: isLoading ? undefined : { pages: [{ data, meta: { items_total: data.length } }] },
|
|
26
|
+
isLoading,
|
|
27
|
+
isFetchingNextPage: false,
|
|
28
|
+
hasNextPage: false,
|
|
29
|
+
error: null,
|
|
30
|
+
fetchNextPage: vi.fn(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function table(data: Row[], restored: number, isLoading = false) {
|
|
35
|
+
return (
|
|
36
|
+
<DataTable<Row>
|
|
37
|
+
query={query(data, isLoading)}
|
|
38
|
+
columns={columns}
|
|
39
|
+
ariaLabel="Records"
|
|
40
|
+
rowHeight={ROW_HEIGHT}
|
|
41
|
+
initialScrollOffset={restored}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('DataTable scroll restore (real virtualizer)', () => {
|
|
47
|
+
const scrollTo = vi.fn()
|
|
48
|
+
const originalScrollTo = HTMLElement.prototype.scrollTo
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
scrollTo.mockClear()
|
|
52
|
+
HTMLElement.prototype.scrollTo = scrollTo
|
|
53
|
+
for (const prop of ['offsetHeight', 'clientHeight'] as const) {
|
|
54
|
+
Object.defineProperty(HTMLElement.prototype, prop, {
|
|
55
|
+
configurable: true,
|
|
56
|
+
value: VIEWPORT_HEIGHT,
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
60
|
+
configurable: true,
|
|
61
|
+
value: 800,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
HTMLElement.prototype.scrollTo = originalScrollTo
|
|
67
|
+
for (const prop of ['offsetHeight', 'clientHeight', 'offsetWidth']) {
|
|
68
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)[prop]
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('opens cached rows at the saved position', () => {
|
|
73
|
+
render(table(rows(100), 1600))
|
|
74
|
+
expect(screen.getByText('Row 50')).toBeInTheDocument()
|
|
75
|
+
expect(screen.queryByText('Row 0')).toBeNull()
|
|
76
|
+
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 1600 }))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('clamps to the end of a list that shrank since the visit', () => {
|
|
80
|
+
render(table(rows(40), 3000))
|
|
81
|
+
// 40 rows × 32 = 1280; the furthest reachable offset is 1280 - 600 = 680.
|
|
82
|
+
expect(screen.getByText('Row 39')).toBeInTheDocument()
|
|
83
|
+
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 680 }))
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('shows rows that fit the viewport instead of an empty body', () => {
|
|
87
|
+
render(table(rows(3), 3000))
|
|
88
|
+
expect(screen.getByText('Row 0')).toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText('Row 2')).toBeInTheDocument()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('shows rows that arrive after a loading first render', () => {
|
|
93
|
+
const { rerender } = render(table([], 3000, true))
|
|
94
|
+
rerender(table(rows(10), 3000))
|
|
95
|
+
expect(screen.getByText('Row 0')).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -47,8 +47,18 @@ function Harness({ onChange }: { onChange?: (ids: Set<string>) => void }) {
|
|
|
47
47
|
// measure (every rect is 0×0, so the virtualizer yields no items and no row
|
|
48
48
|
// ever renders). Swap the virtualizer for a pass-through that emits every
|
|
49
49
|
// row — the windowing itself is TanStack's to test, not ours.
|
|
50
|
+
const virtualizerOptions = vi.hoisted(() => ({
|
|
51
|
+
last: undefined as { initialOffset?: number | (() => number) } | undefined,
|
|
52
|
+
}))
|
|
53
|
+
|
|
50
54
|
vi.mock('@tanstack/react-virtual', () => ({
|
|
51
|
-
useVirtualizer: (
|
|
55
|
+
useVirtualizer: (options: {
|
|
56
|
+
count: number
|
|
57
|
+
estimateSize: () => number
|
|
58
|
+
initialOffset?: number | (() => number)
|
|
59
|
+
}) => {
|
|
60
|
+
virtualizerOptions.last = options
|
|
61
|
+
const { count, estimateSize } = options
|
|
52
62
|
const size = estimateSize()
|
|
53
63
|
return {
|
|
54
64
|
getTotalSize: () => count * size,
|
|
@@ -219,7 +229,12 @@ describe('DataTable scroll restoration', () => {
|
|
|
219
229
|
try {
|
|
220
230
|
const sorting = [{ id: 'name', desc: false }]
|
|
221
231
|
const { rerender } = render(
|
|
222
|
-
<DataTable<Row>
|
|
232
|
+
<DataTable<Row>
|
|
233
|
+
query={query(ROWS)}
|
|
234
|
+
columns={columns}
|
|
235
|
+
ariaLabel="Records"
|
|
236
|
+
sorting={sorting}
|
|
237
|
+
/>,
|
|
223
238
|
)
|
|
224
239
|
expect(scrollTo).not.toHaveBeenCalled()
|
|
225
240
|
|
|
@@ -236,4 +251,9 @@ describe('DataTable scroll restoration', () => {
|
|
|
236
251
|
HTMLElement.prototype.scrollTo = original
|
|
237
252
|
}
|
|
238
253
|
})
|
|
254
|
+
|
|
255
|
+
it('does not seed an offset on a fresh visit', () => {
|
|
256
|
+
render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
|
|
257
|
+
expect(virtualizerOptions.last?.initialOffset).toBeUndefined()
|
|
258
|
+
})
|
|
239
259
|
})
|
|
@@ -423,13 +423,43 @@ export function DataTable<TRow>({
|
|
|
423
423
|
|
|
424
424
|
const headingHeight = groupHeaderHeight ?? rowHeight
|
|
425
425
|
|
|
426
|
+
// The virtualizer reads (and caches) `initialOffset` during render, the
|
|
427
|
+
// first time `getVirtualItems()` runs — before React attaches
|
|
428
|
+
// `scrollerRef`. When restoring, keep it disabled for that first render (a
|
|
429
|
+
// disabled virtualizer caches no offset) and enable it once the scroller is
|
|
430
|
+
// attached; the layout-phase re-render lands before the first paint.
|
|
431
|
+
const [isScrollerAttached, setIsScrollerAttached] = useState(false)
|
|
432
|
+
useLayoutEffect(() => setIsScrollerAttached(true), [])
|
|
433
|
+
|
|
426
434
|
const virtualizer = useVirtualizer({
|
|
435
|
+
enabled: initialScrollOffset === undefined || isScrollerAttached,
|
|
427
436
|
count: listItems.length,
|
|
428
437
|
getScrollElement: () => scrollerRef.current,
|
|
429
438
|
estimateSize: (index) => (listItems[index]?.kind === 'group' ? headingHeight : rowHeight),
|
|
430
439
|
overscan,
|
|
431
440
|
scrollMargin: listOffset,
|
|
432
|
-
|
|
441
|
+
// Read once, with the scroller attached (see `enabled`). Clamp the restored
|
|
442
|
+
// offset to what the scroller will be able to reach: while the skeleton
|
|
443
|
+
// shows, or when the list shrank since the visit, the browser clamps
|
|
444
|
+
// scrollTop WITHOUT firing a scroll event, and the virtualizer — which only
|
|
445
|
+
// re-reads its offset on scroll events — would sit past the rows and paint
|
|
446
|
+
// empty. The reach comes from the row data, not `scrollHeight`: the disabled
|
|
447
|
+
// first render committed an empty spacer, so the DOM can't report it yet.
|
|
448
|
+
...(initialScrollOffset !== undefined
|
|
449
|
+
? {
|
|
450
|
+
initialOffset: () => {
|
|
451
|
+
const el = scrollerRef.current
|
|
452
|
+
if (!el) return 0
|
|
453
|
+
const estimatedSize = listItems.reduce(
|
|
454
|
+
(sum, item) => sum + (item.kind === 'group' ? headingHeight : rowHeight),
|
|
455
|
+
0,
|
|
456
|
+
)
|
|
457
|
+
const listTop = listRef.current?.offsetTop ?? 0
|
|
458
|
+
const reachable = listTop + estimatedSize - el.clientHeight
|
|
459
|
+
return Math.max(0, Math.min(initialScrollOffset, reachable))
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
: {}),
|
|
433
463
|
})
|
|
434
464
|
|
|
435
465
|
const sentinelRef = useInfiniteScrollSentinel<HTMLDivElement>({
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { FrameStrip, type FrameStripItem } from './frame-strip'
|
|
5
|
+
|
|
6
|
+
const HUES = [214, 160, 28, 340, 262, 190]
|
|
7
|
+
|
|
8
|
+
/** A stand-in still: a tinted window with a block somewhere in it. */
|
|
9
|
+
function Still({ index }: { index: number }) {
|
|
10
|
+
const hue = HUES[index % HUES.length] ?? 214
|
|
11
|
+
return (
|
|
12
|
+
<svg viewBox="0 0 160 90" aria-hidden>
|
|
13
|
+
<rect width="160" height="90" fill={`oklch(0.93 0.03 ${hue})`} />
|
|
14
|
+
<rect x="18" y="16" width="124" height="58" rx="4" fill={`oklch(0.8 0.08 ${hue})`} />
|
|
15
|
+
<rect
|
|
16
|
+
x={28 + ((index * 17) % 90)}
|
|
17
|
+
y="30"
|
|
18
|
+
width="30"
|
|
19
|
+
height="30"
|
|
20
|
+
rx="3"
|
|
21
|
+
fill={`oklch(0.5 0.16 ${hue})`}
|
|
22
|
+
/>
|
|
23
|
+
</svg>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const makeItems = (count: number): FrameStripItem[] =>
|
|
28
|
+
Array.from({ length: count }, (_, index) => ({
|
|
29
|
+
key: `frame-${index + 1}`,
|
|
30
|
+
label: `Frame ${index + 1}`,
|
|
31
|
+
at: index * 2.3 + 1.2,
|
|
32
|
+
thumbnail: <Still index={index} />,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
function Frame({ count, width = 420 }: { count: number; width?: number }) {
|
|
36
|
+
const [items] = useState(() => makeItems(count))
|
|
37
|
+
const [selectedKey, setSelectedKey] = useState('frame-1')
|
|
38
|
+
return (
|
|
39
|
+
<div className="border border-rule bg-surface" style={{ width }}>
|
|
40
|
+
<FrameStrip
|
|
41
|
+
aria-label="Frames"
|
|
42
|
+
items={items}
|
|
43
|
+
selectedKey={selectedKey}
|
|
44
|
+
onSelect={setSelectedKey}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const meta: Meta<typeof FrameStrip> = {
|
|
51
|
+
title: 'Components/FrameStrip',
|
|
52
|
+
component: FrameStrip,
|
|
53
|
+
tags: ['autodocs'],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default meta
|
|
57
|
+
type Story = StoryObj<typeof FrameStrip>
|
|
58
|
+
|
|
59
|
+
export const AFewFrames: Story = { render: () => <Frame count={3} /> }
|
|
60
|
+
export const Scrolling: Story = { render: () => <Frame count={14} /> }
|
|
61
|
+
export const Wide: Story = { render: () => <Frame count={5} width={720} /> }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { FrameStrip } from './frame-strip'
|
|
7
|
+
|
|
8
|
+
const ITEMS = [1, 2, 3].map((index) => ({
|
|
9
|
+
key: `f${index}`,
|
|
10
|
+
label: `Frame ${index}`,
|
|
11
|
+
at: index * 2.5,
|
|
12
|
+
thumbnail: <img alt="" src={`data:image/png;base64,${index}`} />,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
function Harness({ initial, onSelect }: { initial?: string; onSelect?: (key: string) => void }) {
|
|
16
|
+
const [selectedKey, setSelectedKey] = useState<string | undefined>(initial)
|
|
17
|
+
return (
|
|
18
|
+
<FrameStrip
|
|
19
|
+
aria-label="Frames"
|
|
20
|
+
items={ITEMS}
|
|
21
|
+
{...(selectedKey !== undefined ? { selectedKey } : {})}
|
|
22
|
+
onSelect={(key) => {
|
|
23
|
+
setSelectedKey(key)
|
|
24
|
+
onSelect?.(key)
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scrollIntoView = vi.fn()
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
// jsdom lays nothing out and has no scrollIntoView to call.
|
|
34
|
+
scrollIntoView.mockClear()
|
|
35
|
+
Element.prototype.scrollIntoView = scrollIntoView
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('FrameStrip', () => {
|
|
39
|
+
it('lists the frames as one exclusive group, each named with its time', () => {
|
|
40
|
+
render(<Harness />)
|
|
41
|
+
expect(screen.getByRole('radiogroup', { name: 'Frames' })).toBeInTheDocument()
|
|
42
|
+
expect(screen.getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
|
|
43
|
+
'Frame 1, at 00:02',
|
|
44
|
+
'Frame 2, at 00:05',
|
|
45
|
+
'Frame 3, at 00:07',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('shows the thumbnail and the caption inside each frame', () => {
|
|
50
|
+
render(<Harness />)
|
|
51
|
+
const frame = screen.getByRole('radio', { name: 'Frame 2, at 00:05' })
|
|
52
|
+
expect(frame.querySelector('img')).not.toBeNull()
|
|
53
|
+
expect(frame).toHaveTextContent('Frame 2')
|
|
54
|
+
expect(frame).toHaveTextContent('00:05')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('marks the selected frame', () => {
|
|
58
|
+
render(<Harness initial="f2" />)
|
|
59
|
+
expect(screen.getByRole('radio', { name: 'Frame 2, at 00:05' })).toBeChecked()
|
|
60
|
+
expect(screen.getByRole('radio', { name: 'Frame 1, at 00:02' })).not.toBeChecked()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('selects on click and reports the key', async () => {
|
|
64
|
+
const user = userEvent.setup()
|
|
65
|
+
const onSelect = vi.fn()
|
|
66
|
+
render(<Harness onSelect={onSelect} />)
|
|
67
|
+
await user.click(screen.getByRole('radio', { name: 'Frame 3, at 00:07' }))
|
|
68
|
+
expect(onSelect).toHaveBeenLastCalledWith('f3')
|
|
69
|
+
expect(screen.getByRole('radio', { name: 'Frame 3, at 00:07' })).toBeChecked()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('keeps the selection when the selected frame is pressed again', async () => {
|
|
73
|
+
const user = userEvent.setup()
|
|
74
|
+
render(<Harness initial="f1" />)
|
|
75
|
+
await user.click(screen.getByRole('radio', { name: 'Frame 1, at 00:02' }))
|
|
76
|
+
expect(screen.getByRole('radio', { name: 'Frame 1, at 00:02' })).toBeChecked()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('moves between frames with the arrow keys and picks with the keyboard', async () => {
|
|
80
|
+
const user = userEvent.setup()
|
|
81
|
+
const onSelect = vi.fn()
|
|
82
|
+
render(<Harness onSelect={onSelect} />)
|
|
83
|
+
await user.tab()
|
|
84
|
+
expect(screen.getByRole('radio', { name: 'Frame 1, at 00:02' })).toHaveFocus()
|
|
85
|
+
await user.keyboard('{ArrowRight}')
|
|
86
|
+
expect(screen.getByRole('radio', { name: 'Frame 2, at 00:05' })).toHaveFocus()
|
|
87
|
+
await user.keyboard('{Enter}')
|
|
88
|
+
expect(onSelect).toHaveBeenLastCalledWith('f2')
|
|
89
|
+
expect(screen.getByRole('radio', { name: 'Frame 2, at 00:05' })).toBeChecked()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps a selection made elsewhere in view', () => {
|
|
93
|
+
const { rerender } = render(
|
|
94
|
+
<FrameStrip aria-label="Frames" items={ITEMS} selectedKey="f1" onSelect={() => {}} />,
|
|
95
|
+
)
|
|
96
|
+
rerender(<FrameStrip aria-label="Frames" items={ITEMS} selectedKey="f3" onSelect={() => {}} />)
|
|
97
|
+
expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'nearest', inline: 'nearest' })
|
|
98
|
+
expect(scrollIntoView.mock.contexts.at(-1)).toBe(
|
|
99
|
+
screen.getByRole('radio', { name: 'Frame 3, at 00:07' }),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('names the group on its own when no label is given', () => {
|
|
104
|
+
render(<FrameStrip items={ITEMS} onSelect={() => {}} />)
|
|
105
|
+
expect(screen.getByRole('radiogroup', { name: 'Frames' })).toBeInTheDocument()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('renders nothing without frames', () => {
|
|
109
|
+
render(<FrameStrip aria-label="Frames" items={[]} onSelect={() => {}} />)
|
|
110
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ToggleButton as AriaToggleButton,
|
|
4
|
+
ToggleButtonGroup as AriaToggleButtonGroup,
|
|
5
|
+
type ToggleButtonGroupProps as AriaToggleButtonGroupProps,
|
|
6
|
+
} from 'react-aria-components'
|
|
7
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../../lib/cn'
|
|
10
|
+
import { formatMediaTime } from '../media-player/media-time'
|
|
11
|
+
|
|
12
|
+
/* =========================================================================
|
|
13
|
+
FrameStrip — a sideways row of stills from a recording, each named and
|
|
14
|
+
stamped with its time, one of them current. Wraps RAC ToggleButtonGroup in
|
|
15
|
+
single selection: one radiogroup, one tab stop, arrow keys between frames,
|
|
16
|
+
so picking a frame reads like picking a moment. The strip scrolls
|
|
17
|
+
horizontally and keeps a selection made elsewhere in view (playback
|
|
18
|
+
reaching the next frame).
|
|
19
|
+
|
|
20
|
+
The thumbnail is the consumer's node (an <img>, a canvas, a placeholder)
|
|
21
|
+
and fills a 16:9 box; the strip only lays the frames out.
|
|
22
|
+
========================================================================= */
|
|
23
|
+
|
|
24
|
+
export const frameStripStyles = tv({
|
|
25
|
+
slots: {
|
|
26
|
+
root: 'flex shrink-0 gap-2 overflow-x-auto overscroll-x-contain scroll-px-[18px] px-[18px] py-3',
|
|
27
|
+
item: [
|
|
28
|
+
'group flex w-[120px] shrink-0 cursor-pointer flex-col gap-1.5 rounded-md p-1 outline-none',
|
|
29
|
+
'transition-[background-color,box-shadow] duration-150',
|
|
30
|
+
'hovered:bg-surface-2',
|
|
31
|
+
'pressed:bg-surface-3',
|
|
32
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
33
|
+
],
|
|
34
|
+
thumbnail: [
|
|
35
|
+
'relative aspect-video w-full overflow-hidden rounded-sm border border-rule bg-surface-2',
|
|
36
|
+
'*:size-full *:object-cover',
|
|
37
|
+
'transition-[border-color,box-shadow] duration-150',
|
|
38
|
+
'group-data-[hovered]:border-rule-strong',
|
|
39
|
+
'group-data-[selected]:border-ink group-data-[selected]:shadow-[inset_0_0_0_1px_var(--color-ink)]',
|
|
40
|
+
],
|
|
41
|
+
caption: 'flex min-w-0 items-baseline gap-1.5 px-0.5',
|
|
42
|
+
label: [
|
|
43
|
+
'min-w-0 flex-1 truncate text-[12px] leading-4 text-ink-2',
|
|
44
|
+
'group-data-[selected]:font-medium group-data-[selected]:text-ink',
|
|
45
|
+
],
|
|
46
|
+
time: 'shrink-0 font-mono text-[11px] leading-4 tabular-nums text-ink-4',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export type FrameStripVariants = VariantProps<typeof frameStripStyles>
|
|
51
|
+
|
|
52
|
+
export interface FrameStripItem {
|
|
53
|
+
key: string
|
|
54
|
+
label: string
|
|
55
|
+
/** Seconds into the recording. */
|
|
56
|
+
at: number
|
|
57
|
+
thumbnail: ReactNode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type FrameStripProps = Omit<
|
|
61
|
+
AriaToggleButtonGroupProps,
|
|
62
|
+
| 'children'
|
|
63
|
+
| 'selectionMode'
|
|
64
|
+
| 'selectedKeys'
|
|
65
|
+
| 'defaultSelectedKeys'
|
|
66
|
+
| 'onSelectionChange'
|
|
67
|
+
| 'disallowEmptySelection'
|
|
68
|
+
| 'orientation'
|
|
69
|
+
| 'className'
|
|
70
|
+
| 'style'
|
|
71
|
+
> & {
|
|
72
|
+
items: ReadonlyArray<FrameStripItem>
|
|
73
|
+
/** The current frame; none when undefined. */
|
|
74
|
+
selectedKey?: string
|
|
75
|
+
onSelect: (key: string) => void
|
|
76
|
+
className?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function FrameStrip({ items, selectedKey, onSelect, className, ...props }: FrameStripProps) {
|
|
80
|
+
const styles = frameStripStyles()
|
|
81
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
82
|
+
|
|
83
|
+
// The selection moved from outside (playback reached the next frame): follow it.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const root = rootRef.current
|
|
86
|
+
if (selectedKey === undefined || !root) return
|
|
87
|
+
for (const element of root.querySelectorAll<HTMLElement>('[data-frame-key]')) {
|
|
88
|
+
if (element.dataset.frameKey !== selectedKey) continue
|
|
89
|
+
if (typeof element.scrollIntoView === 'function')
|
|
90
|
+
element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}, [selectedKey])
|
|
94
|
+
|
|
95
|
+
if (items.length === 0) return null
|
|
96
|
+
|
|
97
|
+
const isLabelled = props['aria-label'] !== undefined || props['aria-labelledby'] !== undefined
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<AriaToggleButtonGroup
|
|
101
|
+
{...(isLabelled ? {} : { 'aria-label': 'Frames' })}
|
|
102
|
+
{...props}
|
|
103
|
+
ref={rootRef}
|
|
104
|
+
orientation="horizontal"
|
|
105
|
+
selectionMode="single"
|
|
106
|
+
disallowEmptySelection
|
|
107
|
+
selectedKeys={selectedKey === undefined ? new Set() : new Set([selectedKey])}
|
|
108
|
+
onSelectionChange={(keys) => {
|
|
109
|
+
const [next] = Array.from(keys)
|
|
110
|
+
if (next !== undefined) onSelect(String(next))
|
|
111
|
+
}}
|
|
112
|
+
className={cn(styles.root(), className)}
|
|
113
|
+
>
|
|
114
|
+
{items.map((item) => (
|
|
115
|
+
<AriaToggleButton
|
|
116
|
+
key={item.key}
|
|
117
|
+
id={item.key}
|
|
118
|
+
data-frame-key={item.key}
|
|
119
|
+
aria-label={`${item.label}, at ${formatMediaTime(item.at)}`}
|
|
120
|
+
className={styles.item()}
|
|
121
|
+
>
|
|
122
|
+
<span className={styles.thumbnail()}>{item.thumbnail}</span>
|
|
123
|
+
<span className={styles.caption()}>
|
|
124
|
+
<span className={styles.label()}>{item.label}</span>
|
|
125
|
+
<span className={styles.time()}>{formatMediaTime(item.at)}</span>
|
|
126
|
+
</span>
|
|
127
|
+
</AriaToggleButton>
|
|
128
|
+
))}
|
|
129
|
+
</AriaToggleButtonGroup>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type { MediaControlsProps, MediaControlsVariants } from './media-controls'
|
|
2
|
+
export { MediaControls, mediaControlsStyles } from './media-controls'
|
|
3
|
+
export type { MediaMarker, MediaMarkersVariants, MediaMarkerTrackProps } from './media-markers'
|
|
4
|
+
export { currentMediaMarkers, MediaMarkerTrack, mediaMarkersStyles } from './media-markers'
|
|
5
|
+
export type { DecodedPeaks, MediaSegment } from './media-peaks'
|
|
6
|
+
export { decodePeaks, MAX_DECODE_BYTES, maskPeaks, peaksFromChannels } from './media-peaks'
|
|
7
|
+
export type {
|
|
8
|
+
MediaPlayerContextValue,
|
|
9
|
+
MediaPlayerKind,
|
|
10
|
+
MediaPlayerProps,
|
|
11
|
+
MediaPlayerSource,
|
|
12
|
+
MediaPlayerVariants,
|
|
13
|
+
MediaWaveformStatus,
|
|
14
|
+
} from './media-player'
|
|
15
|
+
export { MediaPlayer, MediaStage, mediaPlayerStyles, useMediaPlayer } from './media-player'
|
|
16
|
+
export { formatMediaTime, spokenMediaTime } from './media-time'
|
|
17
|
+
export type { MediaTimelineProps, MediaTimelineVariants } from './media-timeline'
|
|
18
|
+
export { MediaTimeline, mediaTimelineStyles } from './media-timeline'
|
|
19
|
+
export type { MediaVideoStageProps, MediaVideoStageVariants } from './media-video-stage'
|
|
20
|
+
export { fitMediaBox, MediaVideoStage, mediaVideoStageStyles } from './media-video-stage'
|
|
21
|
+
export type { MediaLane, MediaWaveformProps, MediaWaveformVariants } from './media-waveform'
|
|
22
|
+
export { MediaWaveform, mediaWaveformStyles } from './media-waveform'
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Pause, Play, RotateCcw, RotateCw, Square } from 'lucide-react'
|
|
2
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../lib/cn'
|
|
5
|
+
import { Button } from '../button/button'
|
|
6
|
+
import { Slider } from '../slider/slider'
|
|
7
|
+
import { useMediaPlayer } from './media-player'
|
|
8
|
+
import { formatMediaTime, spokenMediaTime } from './media-time'
|
|
9
|
+
|
|
10
|
+
/* =========================================================================
|
|
11
|
+
MediaControls — the transport: a seek slider between elapsed and total
|
|
12
|
+
time, then stop · back · play · forward · speed. Play is the view's one
|
|
13
|
+
`cta`. Uses `useMediaPlayer` for everything, so it needs no props beyond
|
|
14
|
+
an optional spoken description of the moment.
|
|
15
|
+
========================================================================= */
|
|
16
|
+
|
|
17
|
+
export const mediaControlsStyles = tv({
|
|
18
|
+
slots: {
|
|
19
|
+
root: 'shrink-0 border-t border-rule-2 px-[18px] pt-3 pb-3.5',
|
|
20
|
+
seek: 'grid grid-cols-[auto_1fr_auto] items-center gap-2.5',
|
|
21
|
+
elapsed: 'font-mono text-[12.5px] leading-none tabular-nums text-ink-2',
|
|
22
|
+
total: 'font-mono text-[12.5px] leading-none tabular-nums text-ink-4',
|
|
23
|
+
transport: 'mt-2 grid grid-cols-[1fr_auto_1fr] items-center',
|
|
24
|
+
center: 'flex items-center gap-1.5',
|
|
25
|
+
end: 'justify-self-end',
|
|
26
|
+
text: 'font-mono',
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export type MediaControlsVariants = VariantProps<typeof mediaControlsStyles>
|
|
31
|
+
|
|
32
|
+
const SKIP_SECONDS = 10
|
|
33
|
+
const SPEEDS = [1, 1.25, 1.5, 2] as const
|
|
34
|
+
|
|
35
|
+
export interface MediaControlsProps {
|
|
36
|
+
/** Added to the slider's spoken position, e.g. who is speaking at that moment. */
|
|
37
|
+
describeTime?: (seconds: number) => string | null
|
|
38
|
+
/** Render the seek row. Off when another scrubber (a `MediaTimeline` with
|
|
39
|
+
* markers) already owns seeking, so the player shows ONE timeline. */
|
|
40
|
+
hasSeek?: boolean
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function MediaControls({ describeTime, hasSeek = true, className }: MediaControlsProps) {
|
|
45
|
+
const {
|
|
46
|
+
isReady,
|
|
47
|
+
isPlaying,
|
|
48
|
+
currentTime,
|
|
49
|
+
duration,
|
|
50
|
+
playbackRate,
|
|
51
|
+
toggle,
|
|
52
|
+
stop,
|
|
53
|
+
seek,
|
|
54
|
+
skip,
|
|
55
|
+
setPlaybackRate,
|
|
56
|
+
} = useMediaPlayer()
|
|
57
|
+
const styles = mediaControlsStyles()
|
|
58
|
+
const description = describeTime?.(currentTime)
|
|
59
|
+
const valueText = `${spokenMediaTime(currentTime)} of ${spokenMediaTime(duration)}${
|
|
60
|
+
description ? `, ${description}` : ''
|
|
61
|
+
}`
|
|
62
|
+
const nextSpeed =
|
|
63
|
+
SPEEDS[(SPEEDS.indexOf(playbackRate as (typeof SPEEDS)[number]) + 1) % SPEEDS.length] ?? 1
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn(styles.root(), className)}>
|
|
67
|
+
{hasSeek ? (
|
|
68
|
+
<div className={styles.seek()}>
|
|
69
|
+
<span className={styles.elapsed()}>{formatMediaTime(currentTime)}</span>
|
|
70
|
+
<Slider
|
|
71
|
+
aria-label="Seek"
|
|
72
|
+
value={currentTime}
|
|
73
|
+
minValue={0}
|
|
74
|
+
maxValue={duration || 1}
|
|
75
|
+
step={1}
|
|
76
|
+
isDisabled={!isReady}
|
|
77
|
+
valueText={valueText}
|
|
78
|
+
onChange={seek}
|
|
79
|
+
/>
|
|
80
|
+
<span className={styles.total()}>
|
|
81
|
+
{duration > 0 ? formatMediaTime(duration) : '--:--'}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
) : null}
|
|
85
|
+
<div className={cn(styles.transport(), !hasSeek && 'mt-0')}>
|
|
86
|
+
<div>
|
|
87
|
+
<Button
|
|
88
|
+
variant="ghost"
|
|
89
|
+
size="icon-md"
|
|
90
|
+
aria-label="Stop"
|
|
91
|
+
isDisabled={!isReady}
|
|
92
|
+
onPress={stop}
|
|
93
|
+
>
|
|
94
|
+
<Square />
|
|
95
|
+
</Button>
|
|
96
|
+
</div>
|
|
97
|
+
<div className={styles.center()}>
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="sm"
|
|
101
|
+
aria-label={`Back ${SKIP_SECONDS} seconds`}
|
|
102
|
+
isDisabled={!isReady}
|
|
103
|
+
onPress={() => skip(-SKIP_SECONDS)}
|
|
104
|
+
className={styles.text()}
|
|
105
|
+
>
|
|
106
|
+
<RotateCcw />
|
|
107
|
+
{SKIP_SECONDS}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
variant="cta"
|
|
111
|
+
size="icon-lg"
|
|
112
|
+
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
113
|
+
isDisabled={!isReady}
|
|
114
|
+
onPress={toggle}
|
|
115
|
+
>
|
|
116
|
+
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
|
|
117
|
+
</Button>
|
|
118
|
+
<Button
|
|
119
|
+
variant="ghost"
|
|
120
|
+
size="sm"
|
|
121
|
+
aria-label={`Forward ${SKIP_SECONDS} seconds`}
|
|
122
|
+
isDisabled={!isReady}
|
|
123
|
+
onPress={() => skip(SKIP_SECONDS)}
|
|
124
|
+
className={styles.text()}
|
|
125
|
+
>
|
|
126
|
+
{SKIP_SECONDS}
|
|
127
|
+
<RotateCw />
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
<div className={styles.end()}>
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="sm"
|
|
134
|
+
aria-label={`Playback speed ${playbackRate} times`}
|
|
135
|
+
isDisabled={!isReady}
|
|
136
|
+
onPress={() => setPlaybackRate(nextSpeed)}
|
|
137
|
+
className={styles.text()}
|
|
138
|
+
>
|
|
139
|
+
{playbackRate}×
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|