@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.
Files changed (32) hide show
  1. package/dist/index.cjs +991 -5
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +592 -2
  4. package/dist/index.d.ts +592 -2
  5. package/dist/index.js +969 -9
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -2
  8. package/src/components/data-table/data-table.scroll-restore.test.tsx +97 -0
  9. package/src/components/data-table/data-table.test.tsx +22 -2
  10. package/src/components/data-table/data-table.tsx +31 -1
  11. package/src/components/frame-strip/frame-strip.stories.tsx +61 -0
  12. package/src/components/frame-strip/frame-strip.test.tsx +112 -0
  13. package/src/components/frame-strip/frame-strip.tsx +131 -0
  14. package/src/components/frame-strip/index.ts +2 -0
  15. package/src/components/media-player/index.ts +22 -0
  16. package/src/components/media-player/media-controls.tsx +145 -0
  17. package/src/components/media-player/media-markers.tsx +108 -0
  18. package/src/components/media-player/media-peaks.test.ts +68 -0
  19. package/src/components/media-player/media-peaks.ts +78 -0
  20. package/src/components/media-player/media-player.stories.tsx +243 -0
  21. package/src/components/media-player/media-player.test.tsx +318 -0
  22. package/src/components/media-player/media-player.tsx +334 -0
  23. package/src/components/media-player/media-time.ts +27 -0
  24. package/src/components/media-player/media-timeline.test.tsx +133 -0
  25. package/src/components/media-player/media-timeline.tsx +78 -0
  26. package/src/components/media-player/media-video-stage.tsx +113 -0
  27. package/src/components/media-player/media-waveform.tsx +330 -0
  28. package/src/components/slider/index.ts +2 -0
  29. package/src/components/slider/slider.stories.tsx +32 -0
  30. package/src/components/slider/slider.test.tsx +35 -0
  31. package/src/components/slider/slider.tsx +89 -0
  32. package/src/index.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.77.0",
3
+ "version": "0.79.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
- "@proteos/sdk": "0.77.0"
59
+ "wavesurfer.js": "8.0.5",
60
+ "@proteos/sdk": "0.79.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: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => {
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> query={query(ROWS)} columns={columns} ariaLabel="Records" sorting={sorting} />,
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
- ...(initialScrollOffset !== undefined ? { initialOffset: initialScrollOffset } : {}),
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,2 @@
1
+ export type { FrameStripItem, FrameStripProps, FrameStripVariants } from './frame-strip'
2
+ export { FrameStrip, frameStripStyles } from './frame-strip'
@@ -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
+ }