cursedbelt 3.0.2 → 4.0.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/react/components/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chapter-list algebra. Pure, so every rule the editor, the player and whatever store an
|
|
3
|
+
* app puts these in all depend on is asserted once here rather than three times, differently.
|
|
4
|
+
*
|
|
5
|
+
* The same shape as `videoCuts.spec.ts` in `cursedbelt-core`, and for the same reason — see
|
|
6
|
+
* the header of `videoChapters.ts` for why the model itself is in THIS package today.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import {
|
|
10
|
+
type ChapterSpan,
|
|
11
|
+
chapterProblem,
|
|
12
|
+
chapterSpans,
|
|
13
|
+
MIN_CHAPTER_SECONDS,
|
|
14
|
+
normalizeChapters,
|
|
15
|
+
type VideoChapter,
|
|
16
|
+
} from './videoChapters';
|
|
17
|
+
|
|
18
|
+
const at = (startSeconds: number, title: string): VideoChapter => ({ startSeconds, title });
|
|
19
|
+
const shape = (chapters: readonly VideoChapter[]): string =>
|
|
20
|
+
chapters.map((chapter) => `${chapter.startSeconds}:${chapter.title}`).join(' ');
|
|
21
|
+
const spanShape = (spans: readonly ChapterSpan[]): string =>
|
|
22
|
+
spans.map((span) => `${span.startSeconds}-${span.endSeconds}:${span.title}`).join(' ');
|
|
23
|
+
|
|
24
|
+
describe('normalizeChapters', () => {
|
|
25
|
+
test('🔴 an out-of-order list comes back in timeline order', () => {
|
|
26
|
+
// Marks get made in the order somebody notices them, which is never the order they play
|
|
27
|
+
// in — you name the ending, then go back for the part you skipped.
|
|
28
|
+
expect(shape(normalizeChapters([at(40, 'Ending'), at(5, 'Intro'), at(20, 'Middle')], 60))).toBe(
|
|
29
|
+
'5:Intro 20:Middle 40:Ending',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('🔴 a start past the end of the video is dropped', () => {
|
|
34
|
+
// The usual source is a stored list read back against a re-encoded (shorter) copy. A
|
|
35
|
+
// chapter out there is unreachable: nothing can seek to it and no divider can be drawn.
|
|
36
|
+
expect(shape(normalizeChapters([at(10, 'Real'), at(90, 'Past the end')], 60))).toBe('10:Real');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a start at the very end leaves no section, so it is dropped too', () => {
|
|
40
|
+
expect(normalizeChapters([at(60, 'On the last frame')], 60)).toEqual([]);
|
|
41
|
+
expect(normalizeChapters([at(60 - MIN_CHAPTER_SECONDS, 'Just fits')], 60)).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a negative start is dropped rather than clamped to zero', () => {
|
|
45
|
+
// Clamping would invent a chapter at 0:00 that nobody placed — and 0:00 is the one start
|
|
46
|
+
// that is most likely to already be taken.
|
|
47
|
+
expect(shape(normalizeChapters([at(-5, 'Before the film'), at(3, 'Real')], 60))).toBe('3:Real');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('🔴 a DUPLICATE start is dropped, and the first one placed wins', () => {
|
|
51
|
+
/*
|
|
52
|
+
* Two chapters at one instant make a section of zero length: unreachable from the menu,
|
|
53
|
+
* and two dividers on the same pixel of the scrub bar. The list would say two sections
|
|
54
|
+
* and the player could only ever show one.
|
|
55
|
+
*/
|
|
56
|
+
expect(shape(normalizeChapters([at(20, 'Kept'), at(20, 'Duplicate')], 60))).toBe('20:Kept');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('…and so is one closer than a section, which is the same defect with a gap in it', () => {
|
|
60
|
+
expect(shape(normalizeChapters([at(20, 'Kept'), at(20.5, 'Too close')], 60))).toBe('20:Kept');
|
|
61
|
+
expect(
|
|
62
|
+
shape(normalizeChapters([at(20, 'Kept'), at(20 + MIN_CHAPTER_SECONDS, 'Far enough')], 60)),
|
|
63
|
+
).toBe('20:Kept 21:Far enough');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('🔴 an EMPTY title is dropped — so is one that is only whitespace', () => {
|
|
67
|
+
// A marker with no name is a divider on the scrub bar that says nothing, which is strictly
|
|
68
|
+
// worse than no divider: it interrupts the bar and answers no question.
|
|
69
|
+
expect(shape(normalizeChapters([at(5, ''), at(10, ' '), at(20, 'Named')], 60))).toBe(
|
|
70
|
+
'20:Named',
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('titles are trimmed, because a leading space is invisible in the menu', () => {
|
|
75
|
+
expect(shape(normalizeChapters([at(5, ' The walk home ')], 60))).toBe('5:The walk home');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('🔴 a NaN start drops its chapter instead of poisoning the sort', () => {
|
|
79
|
+
/*
|
|
80
|
+
* The usual source is the playhead of a `<video>` whose metadata has not loaded. Every
|
|
81
|
+
* comparison against NaN is false, so leaving it in produces an array `sort` calls sorted
|
|
82
|
+
* and is not — and the de-duplication pass then compares each chapter to the wrong
|
|
83
|
+
* neighbour and starts dropping real ones.
|
|
84
|
+
*/
|
|
85
|
+
expect(
|
|
86
|
+
shape(normalizeChapters([at(40, 'Ending'), at(Number.NaN, 'Nowhere'), at(5, 'Intro')], 60)),
|
|
87
|
+
).toBe('5:Intro 40:Ending');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an unknown duration means no chapter can be placed', () => {
|
|
91
|
+
expect(normalizeChapters([at(5, 'Intro')], Number.NaN)).toEqual([]);
|
|
92
|
+
expect(normalizeChapters([at(5, 'Intro')], 0)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a chapter at 0:00 is ordinary — it is where YouTube puts the first one', () => {
|
|
96
|
+
expect(shape(normalizeChapters([at(0, 'Cold open')], 60))).toBe('0:Cold open');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('🔴 chapterProblem — the same refusal in the editor and on whatever stores this', () => {
|
|
101
|
+
const existing = [at(20, 'The middle bit')];
|
|
102
|
+
|
|
103
|
+
test('an empty title is refused before anything else is looked at', () => {
|
|
104
|
+
expect(chapterProblem(at(5, ' '), existing, 60)).toContain('Name the section');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a start past the end is refused, and says what is wrong with it', () => {
|
|
108
|
+
expect(chapterProblem(at(59.5, 'Too late'), existing, 60)).toContain('very end');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a start on top of an existing chapter is refused BY NAME', () => {
|
|
112
|
+
// Naming the chapter that is in the way is the difference between "no" and "no, and here
|
|
113
|
+
// is the one you already made" — the second is actionable without hunting the list.
|
|
114
|
+
const problem = chapterProblem(at(20.2, 'Second thoughts'), existing, 60);
|
|
115
|
+
expect(problem).toContain('The middle bit');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('an unknown duration is refused with the reason, not with a crash', () => {
|
|
119
|
+
expect(chapterProblem(at(5, 'Intro'), existing, Number.NaN)).toContain('length is not known');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a negative start is refused', () => {
|
|
123
|
+
expect(chapterProblem(at(-1, 'Intro'), existing, 60)).toContain('not before it');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('an ordinary chapter has no problem', () => {
|
|
127
|
+
expect(chapterProblem(at(35, 'The walk home'), existing, 60)).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('🔴 the refusals and the drops agree — what one rejects, the other does not keep', () => {
|
|
131
|
+
// One definition of valid. If these two ever disagree, a person is told "saved" about a
|
|
132
|
+
// chapter the read path throws away.
|
|
133
|
+
for (const draft of [at(-1, 'a'), at(59.9, 'b'), at(20.1, 'c'), at(30, ' ')]) {
|
|
134
|
+
expect(chapterProblem(draft, existing, 60)).not.toBeNull();
|
|
135
|
+
expect(normalizeChapters([...existing, draft], 60)).toHaveLength(existing.length);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('chapterSpans — where each section ends', () => {
|
|
141
|
+
test('each chapter runs until the next begins, and the last runs to the end', () => {
|
|
142
|
+
expect(spanShape(chapterSpans([at(0, 'One'), at(20, 'Two'), at(45, 'Three')], 60))).toBe(
|
|
143
|
+
'0-20:One 20-45:Two 45-60:Three',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('🔴 the clip before the first chapter is NOT a span', () => {
|
|
148
|
+
// A video whose first mark is at 0:20 has an unnamed opening. Inventing a section called
|
|
149
|
+
// nothing for it would put a row in the chapters menu that says less than no row at all.
|
|
150
|
+
expect(spanShape(chapterSpans([at(20, 'The middle bit')], 60))).toBe('20-60:The middle bit');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('one chapter is one section, the whole clip', () => {
|
|
154
|
+
expect(spanShape(chapterSpans([at(0, 'All of it')], 60))).toBe('0-60:All of it');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('no chapters is no sections — the ordinary video, with nothing added to its bar', () => {
|
|
158
|
+
expect(chapterSpans([], 60)).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('🔴 a key is the START, so renaming a row does not remount the field being typed in', () => {
|
|
162
|
+
// The editor renders a text input per row. A key derived from the title would change on
|
|
163
|
+
// every keystroke, unmounting the input and throwing the caret out of it on the first
|
|
164
|
+
// letter typed.
|
|
165
|
+
const before = chapterSpans([at(20, 'Middle')], 60);
|
|
166
|
+
const after = chapterSpans([at(20, 'Middl')], 60);
|
|
167
|
+
expect(after[0]?.key).toBe(before[0]?.key as string);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('the spans cover the clip from the first mark onward with no gaps', () => {
|
|
171
|
+
const spans = chapterSpans([at(5, 'A'), at(20, 'B'), at(50, 'C')], 60);
|
|
172
|
+
expect(spans[spans.length - 1]?.endSeconds).toBe(60);
|
|
173
|
+
for (let i = 1; i < spans.length; i++)
|
|
174
|
+
expect(spans[i]?.startSeconds).toBe(spans[i - 1]?.endSeconds as number);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marking points in a video and NAMING them — YouTube chapters, as a model, with no browser
|
|
3
|
+
* and no encoder in it.
|
|
4
|
+
*
|
|
5
|
+
* ── The owner's ask, 2026-09-15 ────────────────────────────────────────────────────────
|
|
6
|
+
* *"allow putting markers on videos at points in time and giving them a title for the section
|
|
7
|
+
* for knowing what that part of the video is about, like youtube chapters."*
|
|
8
|
+
*
|
|
9
|
+
* 🔴 This is the OTHER HALF of the gesture `videoCuts.ts` models, and it is deliberately not
|
|
10
|
+
* threaded through it. A cut is a point that says *remove what follows*; a chapter is a point
|
|
11
|
+
* that says *this part is called X, and it stays*. They look alike on a timeline and they have
|
|
12
|
+
* nothing else in common: a cut list is a REQUEST that costs a full re-encode and arrives back
|
|
13
|
+
* as a second file, while a chapter list is metadata that needs no encoder at all, applies the
|
|
14
|
+
* instant it is saved, and is free to be wrong. Putting a chapter on the cut list would make
|
|
15
|
+
* naming a section queue an ffmpeg job.
|
|
16
|
+
*
|
|
17
|
+
* ── Why the model is separate from the dialog ─────────────────────────────────────────
|
|
18
|
+
* Same reason as the cut list: a chapter list is DATA. It is produced by `VideoChapterEditor`,
|
|
19
|
+
* travels to whatever the app persists it in, and is read back by the player. A server that
|
|
20
|
+
* accepts one will want to re-check it, and "what makes a chapter list valid" must have exactly
|
|
21
|
+
* one definition or the editor and the store will disagree about a list neither of them
|
|
22
|
+
* refused. Nothing in this file imports React, and nothing in it touches the DOM.
|
|
23
|
+
*
|
|
24
|
+
* ── 🔴 Why it is HERE and not in `cursedbelt-core/media`, beside `videoCuts.ts` ────────
|
|
25
|
+
* Because `cursedbelt-core` is consumed BY PUBLISHED VERSION (forge rule 1; `src/noPathDeps.
|
|
26
|
+
* spec.ts` refuses a `file:`/`link:` dependency outright), so a module added to that package
|
|
27
|
+
* is unreachable from this one until it is published — `demo/vite.config.ts` says the same
|
|
28
|
+
* thing in its own header: *"a new subpath importable here the moment it is published"*. A
|
|
29
|
+
* chapter model in core today would mean this package could not typecheck against it, so the
|
|
30
|
+
* feature could not ship at all. The same reasoning, in the same words, put
|
|
31
|
+
* `fileTreeSelection.ts` in this package on 2026-09-15 — see `publicSurface.baseline`.
|
|
32
|
+
*
|
|
33
|
+
* It is written to MOVE: framework-free, no local imports but a type, and the only thing
|
|
34
|
+
* standing between it and `core/media/videoChapters.ts` is a published `cursedbelt-core`. When
|
|
35
|
+
* that happens the migration is a delete plus a re-export, exactly as `react/media/index.ts`
|
|
36
|
+
* already re-exports the cut model.
|
|
37
|
+
*/
|
|
38
|
+
import type { PlayableVideo } from 'cursedbelt-core/media';
|
|
39
|
+
|
|
40
|
+
/** One named section, by the point in the source it BEGINS at. Seconds from the start. */
|
|
41
|
+
export interface VideoChapter {
|
|
42
|
+
startSeconds: number;
|
|
43
|
+
/** What that part is about. Trimmed, and never empty — an unnamed mark is unreadable. */
|
|
44
|
+
title: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The shortest section worth naming, in seconds — and therefore how far apart two chapters
|
|
49
|
+
* have to be to be two chapters.
|
|
50
|
+
*
|
|
51
|
+
* 🔴 It is the anti-duplicate rule and the anti-sliver rule at once, which is why there is one
|
|
52
|
+
* number rather than an equality test plus a floor. Two chapters at the same instant make a
|
|
53
|
+
* section of zero length: it is unreachable from the chapters menu, it renders as a divider
|
|
54
|
+
* with no room between it and the next, and on the scrub bar the two dividers land on the same
|
|
55
|
+
* pixel — so the list says two sections and the player can only ever show one. A second is
|
|
56
|
+
* generous (YouTube's own floor is ten) and still far below anything a person marks on purpose.
|
|
57
|
+
*/
|
|
58
|
+
export const MIN_CHAPTER_SECONDS = 1;
|
|
59
|
+
|
|
60
|
+
/** A chapter with the end it runs to — the next one's start, or the end of the clip. */
|
|
61
|
+
export interface ChapterSpan extends VideoChapter {
|
|
62
|
+
endSeconds: number;
|
|
63
|
+
/**
|
|
64
|
+
* Stable across re-renders and safe as a React key.
|
|
65
|
+
*
|
|
66
|
+
* 🔴 The START, never the title. The editor renders a text field per row: keying on the
|
|
67
|
+
* title would give every keystroke a new key, so React would unmount the field being typed
|
|
68
|
+
* in and the caret would jump out of it on the first letter.
|
|
69
|
+
*/
|
|
70
|
+
key: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A {@link PlayableVideo} that carries its named sections.
|
|
75
|
+
*
|
|
76
|
+
* 🔴 An intersection rather than a field on `PlayableVideo` itself for the reason in this
|
|
77
|
+
* file's header: that type lives in the published `cursedbelt-core`, which this package cannot
|
|
78
|
+
* edit and read in the same commit. It widens, so every existing consumer of `PlayableVideo` —
|
|
79
|
+
* every source, every stage, the player's own props — keeps typechecking untouched, and the
|
|
80
|
+
* day core carries `chapters` this alias becomes `PlayableVideo` and nothing else changes.
|
|
81
|
+
*/
|
|
82
|
+
export type ChapteredVideo = PlayableVideo & { chapters?: VideoChapter[] };
|
|
83
|
+
|
|
84
|
+
/** Whitespace off, and a non-string (an app's row with a null in it) reads as empty. */
|
|
85
|
+
const cleanTitle = (title: unknown): string => (typeof title === 'string' ? title.trim() : '');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Put a raw list into the one canonical form everything downstream may assume: inside the
|
|
89
|
+
* clip, ordered, named, and never two chapters closer than a section.
|
|
90
|
+
*
|
|
91
|
+
* Invalid entries are DROPPED rather than reported — this is the read path, and a list that
|
|
92
|
+
* came back from a store against a slightly different duration must still play. The editor
|
|
93
|
+
* refuses the same things out loud through {@link chapterProblem}, so nothing a person types
|
|
94
|
+
* is silently discarded; the two agree because the second is written in terms of the first.
|
|
95
|
+
*
|
|
96
|
+
* A non-finite start (the usual source is a playhead read from a `<video>` whose metadata has
|
|
97
|
+
* not loaded, which reports `NaN`) drops its chapter instead of poisoning the sort: every
|
|
98
|
+
* comparison against `NaN` is false, which produces an array `sort` calls sorted and is not,
|
|
99
|
+
* and then the de-duplication pass below compares against the wrong neighbour.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizeChapters(
|
|
102
|
+
chapters: readonly VideoChapter[],
|
|
103
|
+
duration: number,
|
|
104
|
+
): VideoChapter[] {
|
|
105
|
+
if (!Number.isFinite(duration) || duration <= 0) return [];
|
|
106
|
+
const clean = chapters
|
|
107
|
+
.map((chapter) => ({ startSeconds: chapter.startSeconds, title: cleanTitle(chapter.title) }))
|
|
108
|
+
.filter(
|
|
109
|
+
(chapter) =>
|
|
110
|
+
chapter.title.length > 0 &&
|
|
111
|
+
Number.isFinite(chapter.startSeconds) &&
|
|
112
|
+
chapter.startSeconds >= 0 &&
|
|
113
|
+
chapter.startSeconds <= duration - MIN_CHAPTER_SECONDS,
|
|
114
|
+
)
|
|
115
|
+
.sort((a, b) => a.startSeconds - b.startSeconds);
|
|
116
|
+
|
|
117
|
+
const out: VideoChapter[] = [];
|
|
118
|
+
for (const chapter of clean) {
|
|
119
|
+
const last = out[out.length - 1];
|
|
120
|
+
// First one wins. The alternative — last one wins — makes a marker somebody placed
|
|
121
|
+
// deliberately disappear when a later, sloppier one lands next to it.
|
|
122
|
+
if (last && chapter.startSeconds - last.startSeconds < MIN_CHAPTER_SECONDS) continue;
|
|
123
|
+
out.push(chapter);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Why this chapter cannot be placed, or `null` when it can.
|
|
130
|
+
*
|
|
131
|
+
* 🔴 A SENTENCE, not a boolean, and the same sentence in the editor and in whatever route ends
|
|
132
|
+
* up storing one. All four refusals are reachable in a few seconds of marking, and each names
|
|
133
|
+
* the thing to do about it rather than the rule it broke.
|
|
134
|
+
*/
|
|
135
|
+
export function chapterProblem(
|
|
136
|
+
draft: VideoChapter,
|
|
137
|
+
existing: readonly VideoChapter[],
|
|
138
|
+
duration: number,
|
|
139
|
+
): string | null {
|
|
140
|
+
if (!Number.isFinite(duration) || duration <= 0)
|
|
141
|
+
return "This video's length is not known yet, so a chapter cannot be placed in it.";
|
|
142
|
+
if (cleanTitle(draft.title).length === 0)
|
|
143
|
+
return 'Name the section first — a chapter with no title is a mark nobody can read.';
|
|
144
|
+
const at = draft.startSeconds;
|
|
145
|
+
if (!Number.isFinite(at) || at < 0)
|
|
146
|
+
return 'A chapter starts at a point in the video, not before it.';
|
|
147
|
+
if (at > duration - MIN_CHAPTER_SECONDS)
|
|
148
|
+
return 'That is at the very end of the clip — a section needs at least a second of video after it.';
|
|
149
|
+
const clash = normalizeChapters(existing, duration).find(
|
|
150
|
+
(chapter) => Math.abs(chapter.startSeconds - at) < MIN_CHAPTER_SECONDS,
|
|
151
|
+
);
|
|
152
|
+
if (clash) return `There is already a chapter within a second of there — "${clash.title}".`;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The sections themselves: every chapter runs until the next one begins, and the last runs to
|
|
158
|
+
* the end of the clip.
|
|
159
|
+
*
|
|
160
|
+
* This is what both surfaces read — the editor's strip and the player's chapters track — from
|
|
161
|
+
* one function, because the alternative is two independent readings of "where does this
|
|
162
|
+
* section end" and a scrub bar whose dividers disagree with the list beside it.
|
|
163
|
+
*
|
|
164
|
+
* The clip before the FIRST chapter is deliberately not a span. A video whose first mark is at
|
|
165
|
+
* 2:00 has an unnamed opening, and inventing a section called nothing for it would put a row
|
|
166
|
+
* in the chapters menu that says less than no row at all.
|
|
167
|
+
*/
|
|
168
|
+
export function chapterSpans(
|
|
169
|
+
chapters: readonly VideoChapter[],
|
|
170
|
+
duration: number,
|
|
171
|
+
): ChapterSpan[] {
|
|
172
|
+
const ordered = normalizeChapters(chapters, duration);
|
|
173
|
+
return ordered.map((chapter, index) => {
|
|
174
|
+
const next = ordered[index + 1];
|
|
175
|
+
return {
|
|
176
|
+
...chapter,
|
|
177
|
+
endSeconds: next ? next.startSeconds : duration,
|
|
178
|
+
key: chapter.startSeconds.toFixed(3),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -90,6 +90,34 @@ function nextOrderFor(column: SortableColumn, order: MediaGalleryOrder): MediaGa
|
|
|
90
90
|
return current === 'asc' ? pair.desc : pair.asc;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* "1,402,653,184" — the size as the EXACT integer it is, grouped.
|
|
95
|
+
*
|
|
96
|
+
* ── 🔴 Why a second size column exists at all ──────────────────────────────────────────
|
|
97
|
+
* Owner, `ideas/collections-09-14.md`: *"In collections I want to see the exact file size in
|
|
98
|
+
* bytes as a column option so I can easily tell if two files are the exact same byte size to
|
|
99
|
+
* help me know if they are duplicates or not."*
|
|
100
|
+
*
|
|
101
|
+
* `formatBytes` — the Size column beside this one — rounds to one decimal, so two files that
|
|
102
|
+
* differ by 400 MB read `1.4 GB` and `1.4 GB`, and two that differ by a single byte are
|
|
103
|
+
* indistinguishable at every scale above a kilobyte. That is not a display preference: the
|
|
104
|
+
* rounded column cannot do the one job he named for it, and no amount of squinting at it will
|
|
105
|
+
* ever separate a duplicate from a near-duplicate. So the exact count is offered BESIDE the
|
|
106
|
+
* rounded one ("as a column option", his words) rather than replacing it — `1.4 GB` is still
|
|
107
|
+
* the faster read for "is this big", and both questions are real.
|
|
108
|
+
*
|
|
109
|
+
* Grouped rather than a bare run of digits, because `1402653184` and `1402653284` are the same
|
|
110
|
+
* ten characters to a reader scanning a column, which is the failure this exists to fix. The
|
|
111
|
+
* separator is the reader's LOCALE's, exactly as `createdLabel` takes its month name from the
|
|
112
|
+
* locale — the digits are the fact, the grouping is typography.
|
|
113
|
+
*
|
|
114
|
+
* Null for a row with no size, so the cell shows `—` like every other absent measurement.
|
|
115
|
+
*/
|
|
116
|
+
export function exactBytesLabel(bytes: number | undefined): string | null {
|
|
117
|
+
if (bytes == null || !Number.isFinite(bytes)) return null;
|
|
118
|
+
return Math.round(bytes).toLocaleString();
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
/** "1:04:12" / "3:07" — a duration a person reads, never a raw seconds count. */
|
|
94
122
|
export function durationLabel(seconds: number | undefined): string | null {
|
|
95
123
|
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null;
|
|
@@ -149,6 +177,30 @@ export interface GalleryTableProps {
|
|
|
149
177
|
* which view is on screen, so the button was present, lit, and had nothing wired to it here.
|
|
150
178
|
*/
|
|
151
179
|
autoPreview?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* The row the STAGE is showing, and what to call it — `'Now playing'` on a film, `'On
|
|
182
|
+
* screen'` on a still. Absent in the browse view, where nothing is on a stage.
|
|
183
|
+
*
|
|
184
|
+
* 🔴 The detail view's related list is this same table over the same items the grid shows,
|
|
185
|
+
* and since 2026-09-16 that list INCLUDES the open one (see `MediaGallery`'s strip). A row
|
|
186
|
+
* that is in the list and unmarked is the list lying about which file you are looking at,
|
|
187
|
+
* so the two views of the strip mark it the same way or they are two different features.
|
|
188
|
+
*/
|
|
189
|
+
playing?: { id: string; label: string };
|
|
190
|
+
/**
|
|
191
|
+
* Offer the EXACT byte count as its own column, beside the rounded Size one.
|
|
192
|
+
*
|
|
193
|
+
* Off by default, so every consumer that passes nothing renders the six columns it already
|
|
194
|
+
* had. See {@link exactBytesLabel} for what the column is for and why it is an addition
|
|
195
|
+
* rather than a replacement.
|
|
196
|
+
*
|
|
197
|
+
* 🔴 It is deliberately NOT a sort header, though it is a number. It holds the same value
|
|
198
|
+
* `size` already sorts on, so a second sortable header would put two `aria-sort` states on
|
|
199
|
+
* one ordering — a table where clicking Size and clicking Bytes are the same act, announced
|
|
200
|
+
* as two. Sort by Size and the exact counts land in the same order; that is what brings two
|
|
201
|
+
* candidates for a duplicate next to each other.
|
|
202
|
+
*/
|
|
203
|
+
exactBytes?: boolean;
|
|
152
204
|
}
|
|
153
205
|
|
|
154
206
|
export function GalleryTable({
|
|
@@ -163,6 +215,8 @@ export function GalleryTable({
|
|
|
163
215
|
selection,
|
|
164
216
|
drag,
|
|
165
217
|
autoPreview,
|
|
218
|
+
playing,
|
|
219
|
+
exactBytes = false,
|
|
166
220
|
}: GalleryTableProps) {
|
|
167
221
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
168
222
|
const [scrollTop, setScrollTop] = useState(0);
|
|
@@ -183,8 +237,10 @@ export function GalleryTable({
|
|
|
183
237
|
setScrollTop(event.currentTarget.scrollTop);
|
|
184
238
|
}, []);
|
|
185
239
|
|
|
186
|
-
// check? + thumb + name + kind + size + length + date
|
|
187
|
-
|
|
240
|
+
// check? + thumb + name + kind + size + bytes? + length + date. 🔴 The spacer rows span
|
|
241
|
+
// this, so a column added without it is a virtualized table whose geometry is one cell
|
|
242
|
+
// narrower than its data — see the spacer rows below.
|
|
243
|
+
const columnCount = 6 + (selection ? 1 : 0) + (exactBytes ? 1 : 0);
|
|
188
244
|
const above = start * GALLERY_TABLE_ROW_HEIGHT;
|
|
189
245
|
const below = Math.max(0, (items.length - end) * GALLERY_TABLE_ROW_HEIGHT);
|
|
190
246
|
|
|
@@ -234,6 +290,15 @@ export function GalleryTable({
|
|
|
234
290
|
onOrderChange={onOrderChange}
|
|
235
291
|
className='cbgt-cell--num'
|
|
236
292
|
/>
|
|
293
|
+
{/* The exact count, next to the rounded one it exists to disambiguate — a
|
|
294
|
+
duplicate hunt reads the two columns together, so putting it at the far end
|
|
295
|
+
of the row beside the date would be the same data and a worse instrument.
|
|
296
|
+
Not a sort header: see `GalleryTableProps.exactBytes`. */}
|
|
297
|
+
{exactBytes ? (
|
|
298
|
+
<th className='cbgt-cell--num cbgt-cell--bytes' scope='col'>
|
|
299
|
+
Bytes
|
|
300
|
+
</th>
|
|
301
|
+
) : null}
|
|
237
302
|
<th className='cbgt-cell--num cbgt-cell--length' scope='col'>
|
|
238
303
|
Length
|
|
239
304
|
</th>
|
|
@@ -266,6 +331,8 @@ export function GalleryTable({
|
|
|
266
331
|
selection={selection?.(item)}
|
|
267
332
|
drag={drag?.(item)}
|
|
268
333
|
autoPreview={autoPreview === true}
|
|
334
|
+
exactBytes={exactBytes}
|
|
335
|
+
{...(playing?.id === item.id ? { stageMark: playing.label } : {})}
|
|
269
336
|
/>
|
|
270
337
|
))}
|
|
271
338
|
{below > 0 ? <tr className='cbgt-spacer' style={{ height: below }}><td colSpan={columnCount} /></tr> : null}
|
|
@@ -375,6 +442,8 @@ function GalleryTableRow({
|
|
|
375
442
|
selection,
|
|
376
443
|
drag,
|
|
377
444
|
autoPreview,
|
|
445
|
+
exactBytes,
|
|
446
|
+
stageMark,
|
|
378
447
|
}: {
|
|
379
448
|
item: MediaGalleryItem;
|
|
380
449
|
cache: MediaCache;
|
|
@@ -383,12 +452,17 @@ function GalleryTableRow({
|
|
|
383
452
|
selection?: { selected: boolean; onToggle: (shift: boolean) => void } | undefined;
|
|
384
453
|
drag?: { onDragStart: (event: DragEvent<HTMLElement>) => void; onDragEnd?: () => void } | undefined;
|
|
385
454
|
autoPreview: boolean;
|
|
455
|
+
/** Draw the exact-bytes cell — see {@link GalleryTableProps.exactBytes}. */
|
|
456
|
+
exactBytes: boolean;
|
|
457
|
+
/** Set only on the row the stage is showing — see {@link GalleryTableProps.playing}. */
|
|
458
|
+
stageMark?: string;
|
|
386
459
|
}) {
|
|
387
460
|
const { media } = useItemMedia(item, cache);
|
|
388
461
|
const category = galleryItemCategory(item);
|
|
389
462
|
const thumb =
|
|
390
463
|
media.thumbnailUrl ?? media.posterUrl ?? (category === 'image' ? media.url : undefined);
|
|
391
464
|
const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
|
|
465
|
+
const bytes = exactBytesLabel(item.sizeBytes);
|
|
392
466
|
const length = durationLabel(item.durationSeconds);
|
|
393
467
|
const date = createdLabel(item.createdAt);
|
|
394
468
|
|
|
@@ -407,7 +481,11 @@ function GalleryTableRow({
|
|
|
407
481
|
|
|
408
482
|
return (
|
|
409
483
|
<tr
|
|
410
|
-
className={`cbgt-row${selection?.selected ? ' is-selected' : ''}`}
|
|
484
|
+
className={`cbgt-row${selection?.selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}`}
|
|
485
|
+
// The same two hooks the grid tile carries: the attribute is what the gallery's reveal
|
|
486
|
+
// scrolls to, and `aria-current` is what says "this is the one" to a screen reader.
|
|
487
|
+
data-playing={stageMark ? 'true' : undefined}
|
|
488
|
+
aria-current={stageMark ? 'true' : undefined}
|
|
411
489
|
{...(previewSrc
|
|
412
490
|
? {
|
|
413
491
|
onMouseEnter: () => setHovering(true),
|
|
@@ -484,12 +562,24 @@ function GalleryTableRow({
|
|
|
484
562
|
>
|
|
485
563
|
{item.name}
|
|
486
564
|
</button>
|
|
565
|
+
{/*
|
|
566
|
+
* 🔴 The mark is the ROW, not a capsule beside the name, and that is a table
|
|
567
|
+
* constraint rather than a preference. `.cbgt-cell--name` is a fixed-width cell in a
|
|
568
|
+
* `table-layout: fixed` table holding a `display: block` button at 100% width — a
|
|
569
|
+
* second visible child there wraps to a line the 52px row clips, and making the cell
|
|
570
|
+
* a flex box takes it out of the table layout so that one row's columns stop lining
|
|
571
|
+
* up with every other row's. So the row is tinted and barred (`.cbgt-row.is-playing`,
|
|
572
|
+
* the same shape `.is-selected` uses), which is the table idiom for "this one", and
|
|
573
|
+
* the name it is given lives here where a screen reader reads it with the file name.
|
|
574
|
+
*/}
|
|
575
|
+
{stageMark ? <span className='cbgd-sr-only'>{stageMark}</span> : null}
|
|
487
576
|
</td>
|
|
488
577
|
<td className='cbgt-cell--kind'>
|
|
489
578
|
{/* Filled capsule, per DESIGN.md §3 — never a thin outline. */}
|
|
490
579
|
<span className='cbgt-kind'>{CATEGORY_LABEL[category]}</span>
|
|
491
580
|
</td>
|
|
492
581
|
<td className='cbgt-cell--num'>{size ?? '—'}</td>
|
|
582
|
+
{exactBytes ? <td className='cbgt-cell--num cbgt-cell--bytes'>{bytes ?? '—'}</td> : null}
|
|
493
583
|
<td className='cbgt-cell--num cbgt-cell--length'>{length ?? '—'}</td>
|
|
494
584
|
<td className='cbgt-cell--num cbgt-cell--date'>{date ?? '—'}</td>
|
|
495
585
|
</tr>
|