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
|
@@ -1,4 +1,61 @@
|
|
|
1
1
|
import './mediaGallery.css';
|
|
2
|
+
/*
|
|
3
|
+
* โโ ๐ด The toolbar's icons โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
4
|
+
*
|
|
5
|
+
* Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like
|
|
6
|
+
* collections uses I want to make the toolbar look more professional. Make it have icons
|
|
7
|
+
* only, instead of words in the strip of tools where have things like pin, stop at the end,
|
|
8
|
+
* and turn video. Hovering over the tooltip can show the words."*
|
|
9
|
+
*
|
|
10
|
+
* `lucide-react`, not the ๐ โณ ๐ผ โ these pills carried until 2026-09-16. An emoji is drawn
|
|
11
|
+
* by whichever font the operating system picked, at whatever optical size and weight that
|
|
12
|
+
* font chose, in colour on some platforms and monochrome on others โ so the one thing a
|
|
13
|
+
* toolbar of them cannot look is designed, which is the opposite of what was asked for. The
|
|
14
|
+
* package is already a dependency (`DropdownMenu`, `PlayerStrip`, `icons.tsx`), so this adds
|
|
15
|
+
* no weight and no second icon vocabulary.
|
|
16
|
+
*
|
|
17
|
+
* ๐ด Three rules every conversion below keeps, because a visual change is exactly where each
|
|
18
|
+
* is silently dropped:
|
|
19
|
+
*
|
|
20
|
+
* 1. **The accessible name survives.** Every icon-only pill carries an `aria-label`, and
|
|
21
|
+
* the glyph itself is `aria-hidden`. The words did double duty as the name.
|
|
22
|
+
* 2. **The 44px tap floor survives.** The hit target is the pill, not the glyph โ see
|
|
23
|
+
* `.cbgd-order-btn.is-icon` in `denseGallery.css`, measured by
|
|
24
|
+
* `e2e/touch-targets.spec.ts` at a real coarse-pointer viewport.
|
|
25
|
+
* 3. **A word that was a VALUE keeps a home.** `๐ Pin` vs `๐ Pinned` and the five
|
|
26
|
+
* end-behaviour labels were not names, they were readouts; an icon identical in both
|
|
27
|
+
* states deletes information. They move into the pressed state, the accessible name and
|
|
28
|
+
* the tooltip together โ `MediaGallery.spec.tsx` asserts each one.
|
|
29
|
+
*
|
|
30
|
+
* What stays a word: the dwell READING (`5s`), the selection count, the sort pills, and the
|
|
31
|
+
* inline rename form's Save/Cancel. Those are values you read or a form's action row
|
|
32
|
+
* (`DESIGN.md` ยง6), not tools in the strip.
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
ArrowLeft,
|
|
36
|
+
ArrowRightToLine,
|
|
37
|
+
Camera,
|
|
38
|
+
Crop,
|
|
39
|
+
Download,
|
|
40
|
+
Image as ImageIcon,
|
|
41
|
+
LayoutGrid,
|
|
42
|
+
ListVideo,
|
|
43
|
+
Pencil,
|
|
44
|
+
Pin,
|
|
45
|
+
Play,
|
|
46
|
+
Repeat,
|
|
47
|
+
Repeat1,
|
|
48
|
+
RotateCw,
|
|
49
|
+
Rows3,
|
|
50
|
+
Scissors,
|
|
51
|
+
Shuffle,
|
|
52
|
+
SkipForward,
|
|
53
|
+
SquareCheck,
|
|
54
|
+
Sun,
|
|
55
|
+
Tags,
|
|
56
|
+
Timer,
|
|
57
|
+
X,
|
|
58
|
+
} from 'lucide-react';
|
|
2
59
|
import { formatBytes } from 'cwip/format';
|
|
3
60
|
import type { FileCategory } from 'cwip/file-kind';
|
|
4
61
|
import React, {
|
|
@@ -34,6 +91,7 @@ import {
|
|
|
34
91
|
} from '../media/pictureEdit';
|
|
35
92
|
import type { PictureEditResult } from '../media/PictureEditor';
|
|
36
93
|
import type { MediaOrientation } from '../media/VideoTurner';
|
|
94
|
+
import type { ChapteredVideo, VideoChapter } from '../media/videoChapters';
|
|
37
95
|
import type { VideoCut } from 'cursedbelt-core/media/video-cuts';
|
|
38
96
|
import {
|
|
39
97
|
GALLERY_ORDER_OPTIONS,
|
|
@@ -54,8 +112,11 @@ import {
|
|
|
54
112
|
readImageDwell,
|
|
55
113
|
readPinStage,
|
|
56
114
|
readPlaybackEnd,
|
|
115
|
+
readSessionVolume,
|
|
57
116
|
readThumbAutoplay,
|
|
117
|
+
STAGE_VOLUME_KEY,
|
|
58
118
|
writeImageDwell,
|
|
119
|
+
writeSessionVolume,
|
|
59
120
|
writePinStage,
|
|
60
121
|
writePlaybackEnd,
|
|
61
122
|
writeThumbAutoplay,
|
|
@@ -63,7 +124,8 @@ import {
|
|
|
63
124
|
readViewMode,
|
|
64
125
|
writeViewMode,
|
|
65
126
|
} from './playbackPreferences';
|
|
66
|
-
import {
|
|
127
|
+
import type { MediaMetaChange } from './mediaMeta';
|
|
128
|
+
import { GalleryTable, durationLabel } from './GalleryTable';
|
|
67
129
|
import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun';
|
|
68
130
|
import type { MediaGalleryItem, MediaGalleryItemMedia, MediaGalleryOrder } from './types';
|
|
69
131
|
import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia';
|
|
@@ -104,6 +166,58 @@ const LazyFrameGrabber = React.lazy(() =>
|
|
|
104
166
|
const LazyVideoTurner = React.lazy(() =>
|
|
105
167
|
import('../media/VideoTurner').then((m) => ({ default: m.VideoTurner })),
|
|
106
168
|
);
|
|
169
|
+
/**
|
|
170
|
+
* The turn dialog, over the item's RESOLVED media โ never over the raw row.
|
|
171
|
+
*
|
|
172
|
+
* โโ ๐ด The black box, and where it came from โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
173
|
+
* Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
|
|
174
|
+
* screen. It should show me the video so I can see how the turn impacts it."*
|
|
175
|
+
*
|
|
176
|
+
* The dialog was mounted as `src={item.streamUrl ?? item.url ?? ''}` โ the fields ON THE ROW.
|
|
177
|
+
* But a gallery item is allowed to carry no URL at all: {@link MediaGalleryItem.resolveMedia}
|
|
178
|
+
* is the documented path for signed URLs and for bytes that become an object URL, and every
|
|
179
|
+
* other surface in this file reads an item through {@link useItemMedia}, which merges that
|
|
180
|
+
* resolver's answer into the row's own fields and caches it per id. So on any app that signs
|
|
181
|
+
* its URLs โ which is the app that reported this โ both fields were `undefined`, the dialog
|
|
182
|
+
* mounted a `<video>` with no `src` and no `poster`, and an element with nothing to load never
|
|
183
|
+
* even fires `error`: it paints black, forever, silently. Reproduced in
|
|
184
|
+
* `MediaGallery.spec.tsx`'s "the dialog is handed the RESOLVED source" before this existed.
|
|
185
|
+
*
|
|
186
|
+
* ๐ด It shares the gallery's own cache rather than calling the resolver again. A second cache
|
|
187
|
+
* would re-sign the URL every time the dialog opened, which is the cost `galleryItemMedia.tsx`
|
|
188
|
+
* was extracted to stop.
|
|
189
|
+
*/
|
|
190
|
+
function TurnVideoDialog({
|
|
191
|
+
item,
|
|
192
|
+
cache,
|
|
193
|
+
onClose,
|
|
194
|
+
onSave,
|
|
195
|
+
}: {
|
|
196
|
+
item: MediaGalleryItem;
|
|
197
|
+
cache: MediaCache;
|
|
198
|
+
onClose: () => void;
|
|
199
|
+
onSave: (next: MediaOrientation | undefined) => void | Promise<void>;
|
|
200
|
+
}) {
|
|
201
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
202
|
+
// `streamUrl ?? url`, the same preference the stage and the frame grabber make: a plain `url`
|
|
203
|
+
// is not always a streamable source, and a turner given the wrong one shows a black box.
|
|
204
|
+
const src = media.streamUrl ?? media.url ?? '';
|
|
205
|
+
const poster = media.posterUrl ?? media.thumbnailUrl;
|
|
206
|
+
return (
|
|
207
|
+
<LazyVideoTurner
|
|
208
|
+
src={src}
|
|
209
|
+
{...(poster ? { posterUrl: poster } : {})}
|
|
210
|
+
// So an unresolved source reads as "still finding it" for the moment it takes, rather
|
|
211
|
+
// than flashing the "cannot show it" reason at somebody whose film is on its way.
|
|
212
|
+
resolving={resolving}
|
|
213
|
+
name={item.name}
|
|
214
|
+
orientation={item.orientation}
|
|
215
|
+
onClose={onClose}
|
|
216
|
+
onSave={onSave}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
107
221
|
// Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
|
|
108
222
|
// has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
|
|
109
223
|
// of this โ which is what makes "enabled for collections only, for now" a fact about the
|
|
@@ -111,6 +225,45 @@ const LazyVideoTurner = React.lazy(() =>
|
|
|
111
225
|
const LazyVideoCutEditor = React.lazy(() =>
|
|
112
226
|
import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })),
|
|
113
227
|
);
|
|
228
|
+
// Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
|
|
229
|
+
// nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
|
|
230
|
+
// this dialog โ the capability and the bundle cost turn on together, with no flag between them.
|
|
231
|
+
const LazyVideoChapterEditor = React.lazy(() =>
|
|
232
|
+
import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })),
|
|
233
|
+
);
|
|
234
|
+
// Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
|
|
235
|
+
// with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
|
|
236
|
+
// dialog โ and the gallery keeps reading whatever tags and descriptions its items already
|
|
237
|
+
// carry either way, because reading was never the capability that was missing.
|
|
238
|
+
const LazyMediaMetaEditor = React.lazy(() =>
|
|
239
|
+
import('./MediaMetaEditor').then((m) => ({ default: m.MediaMetaEditor })),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One glyph per end-behaviour โ the five values the cycling pill used to spell out.
|
|
244
|
+
*
|
|
245
|
+
* ๐ด Five DISTINCT icons, not one icon plus a lit state. The button cycles through settings
|
|
246
|
+
* rather than toggling one, so `Stop at the end` and `Shuffle` have to be told apart at a
|
|
247
|
+
* glance or the only way to read the current value is to hover โ and the owner's own words
|
|
248
|
+
* for this control ("stop at the end") are a value he was reading off the strip, not a name
|
|
249
|
+
* for the button. `aria-label` still spells the value out in full; this is the sighted half
|
|
250
|
+
* of the same fact.
|
|
251
|
+
*
|
|
252
|
+
* `stop` is `ArrowRightToLine` because it is exactly the `โ|` the pill drew before, and it
|
|
253
|
+
* is the one setting that renders UNLIT (`is-active` is withheld for `stop`) โ so "nothing
|
|
254
|
+
* happens at the end" reads as an off state, which is what it is.
|
|
255
|
+
*
|
|
256
|
+
* Deliberately NOT in `playbackPreferences.ts` beside the labels: that module is pure data
|
|
257
|
+
* and storage, imported by apps that drive their own player, and adding a React component to
|
|
258
|
+
* it would pull `lucide-react` into every one of them for a table they never render.
|
|
259
|
+
*/
|
|
260
|
+
const PLAYBACK_END_ICONS: Record<PlaybackEndBehavior, typeof Repeat> = {
|
|
261
|
+
stop: ArrowRightToLine,
|
|
262
|
+
repeat: Repeat1,
|
|
263
|
+
next: SkipForward,
|
|
264
|
+
all: Repeat,
|
|
265
|
+
shuffle: Shuffle,
|
|
266
|
+
};
|
|
114
267
|
|
|
115
268
|
/**
|
|
116
269
|
* A stored orientation, as a CSS transform โ the same `previewTransform` the editor previews
|
|
@@ -133,13 +286,45 @@ function isTurned(item: MediaGalleryItem): boolean {
|
|
|
133
286
|
|
|
134
287
|
type BytesCache = MutableRefObject<Map<string, Promise<ArrayBuffer>>>;
|
|
135
288
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
289
|
+
/**
|
|
290
|
+
* The one meta line under a tile's picture โ its tags, its measurement, or both.
|
|
291
|
+
*
|
|
292
|
+
* โโ ๐ด What `measure` turns on, and why it is not the default โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
293
|
+
* Owner, `ideas/collections-09-14.md`: *"in the grid view show the video length instead of
|
|
294
|
+
* the size"*, and `ideas/binary-server-09-15.md`: *"show the video length on thumbnail
|
|
295
|
+
* components โฆ show the file size on thumbnail components (maybe controlled through param on
|
|
296
|
+
* media gallery in cb)"*. He guessed the right home for it: ONE param on this gallery, so an
|
|
297
|
+
* app that asks for nothing renders the line it already had, byte for byte.
|
|
298
|
+
*
|
|
299
|
+
* With it on, the measurement a tile leads with is the one that means something for its kind
|
|
300
|
+
* โ a film and a track show their RUNNING TIME, everything else its size. A clip whose
|
|
301
|
+
* duration nobody measured falls back to its size rather than to a blank caption, and a file
|
|
302
|
+
* with neither shows `โ`, the same mark the table's columns use for an absent measurement.
|
|
303
|
+
*
|
|
304
|
+
* ๐ด Tags and the measurement share this line rather than taking one each. The caption block
|
|
305
|
+
* is a fixed 65px โ title, this line, the date โ pinned to `CBGD_META_HEIGHT` because a
|
|
306
|
+
* caption whose height varied per tile is what made rows lap one another across the fleet
|
|
307
|
+
* (see `denseGallery.css`). A fourth line is not available at any price, so a tagged video
|
|
308
|
+
* reads `holiday ยท beach ยท 3:07`.
|
|
309
|
+
*
|
|
310
|
+
* Exported for its own spec: the branch table here is wider than the tile is, and a rendered
|
|
311
|
+
* assertion can only reach the combinations a fixture happens to contain.
|
|
312
|
+
*/
|
|
313
|
+
export function galleryTileLine(
|
|
314
|
+
item: MediaGalleryItem,
|
|
315
|
+
options: { measure?: boolean } = {},
|
|
316
|
+
): string | null {
|
|
317
|
+
const tags = item.tags && item.tags.length > 0 ? item.tags.slice(0, 3).join(' ยท ') : null;
|
|
318
|
+
const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
|
|
319
|
+
if (!options.measure) return tags ?? size;
|
|
320
|
+
const category = galleryItemCategory(item);
|
|
321
|
+
const timeBased = category === 'video' || category === 'audio';
|
|
322
|
+
const measure = timeBased ? (durationLabel(item.durationSeconds) ?? size ?? 'โ') : size;
|
|
323
|
+
return tags && measure ? `${tags} ยท ${measure}` : (tags ?? measure);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function GalleryMeta({ item, measure }: { item: MediaGalleryItem; measure?: boolean }) {
|
|
327
|
+
const line = galleryTileLine(item, { measure: measure === true });
|
|
143
328
|
// The date is its OWN line rather than appended to the one above, because that line is
|
|
144
329
|
// already whichever of tags/size exists โ folding a third fact into it would push the tags
|
|
145
330
|
// out of a tile caption that is two lines tall by design.
|
|
@@ -194,18 +379,34 @@ function TileShell({
|
|
|
194
379
|
name,
|
|
195
380
|
selection,
|
|
196
381
|
drag,
|
|
382
|
+
stageMark,
|
|
197
383
|
children,
|
|
198
384
|
}: {
|
|
199
385
|
name: string;
|
|
200
386
|
selection?: TileSelection;
|
|
201
387
|
drag?: TileDrag;
|
|
388
|
+
/**
|
|
389
|
+
* The mark for the tile the STAGE is showing โ `'Now playing'` on a film, `'On screen'` on
|
|
390
|
+
* a still. Absent on every other tile, and absent entirely in the browse grid, where
|
|
391
|
+
* nothing is on a stage.
|
|
392
|
+
*
|
|
393
|
+
* ๐ด A STATE on the tile, not a second control. The tile that is playing stays exactly the
|
|
394
|
+
* tile it was โ same position, same size, same click โ and gains a ring and one capsule
|
|
395
|
+
* (DESIGN.md ยง3). `aria-current` is what carries the same fact to a screen reader, because
|
|
396
|
+
* a ring drawn by a stylesheet says nothing to one.
|
|
397
|
+
*/
|
|
398
|
+
stageMark?: string;
|
|
202
399
|
children: ReactNode;
|
|
203
400
|
}) {
|
|
204
401
|
const selected = selection?.selected === true;
|
|
205
402
|
const quiet = selection !== undefined && !selection.armed;
|
|
206
403
|
return (
|
|
207
404
|
<div
|
|
208
|
-
className={`cbmg-selectable${selected ? ' is-selected' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`}
|
|
405
|
+
className={`cbmg-selectable${selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`}
|
|
406
|
+
// The hook the reveal below finds the tile by โ see `MediaGallery`'s `revealPlaying`.
|
|
407
|
+
// An attribute rather than the class, so the query cannot be broken by a styling change.
|
|
408
|
+
data-playing={stageMark ? 'true' : undefined}
|
|
409
|
+
aria-current={stageMark ? 'true' : undefined}
|
|
209
410
|
// ๐ด The DRAGGABLE element is this wrapper, not the tile inside it. Every tile branch
|
|
210
411
|
// is a <button>, and a draggable button is a browser-by-browser coin toss: some start
|
|
211
412
|
// a drag, some treat the pointer-down as a press and never fire dragstart at all.
|
|
@@ -220,6 +421,14 @@ function TileShell({
|
|
|
220
421
|
: {})}
|
|
221
422
|
>
|
|
222
423
|
{children}
|
|
424
|
+
{/* Real text, never `aria-hidden`: "the one that is playing" is the whole point of the
|
|
425
|
+
mark, so it has to be readable rather than only visible. The glyph beside it is the
|
|
426
|
+
decoration, and it is the only part hidden. */}
|
|
427
|
+
{stageMark ? (
|
|
428
|
+
<span className='cbmg-playing-chip'>
|
|
429
|
+
<span aria-hidden='true'>โถ</span> {stageMark}
|
|
430
|
+
</span>
|
|
431
|
+
) : null}
|
|
223
432
|
{selection ? (
|
|
224
433
|
<SelectBox name={name} selected={selected} onToggle={selection.onToggle} />
|
|
225
434
|
) : null}
|
|
@@ -266,6 +475,8 @@ function GalleryTile({
|
|
|
266
475
|
selection,
|
|
267
476
|
drag,
|
|
268
477
|
autoPreview,
|
|
478
|
+
measure,
|
|
479
|
+
stageMark,
|
|
269
480
|
}: {
|
|
270
481
|
item: MediaGalleryItem;
|
|
271
482
|
cache: MediaCache;
|
|
@@ -274,6 +485,10 @@ function GalleryTile({
|
|
|
274
485
|
drag?: TileDrag;
|
|
275
486
|
/** Preview every video tile without hovering it โ see `readThumbAutoplay`. */
|
|
276
487
|
autoPreview?: boolean;
|
|
488
|
+
/** Lead the caption with a running time / a size โ see {@link galleryTileLine}. */
|
|
489
|
+
measure?: boolean;
|
|
490
|
+
/** See {@link TileShell}'s `stageMark` โ the label for the tile that is ON the stage. */
|
|
491
|
+
stageMark?: string;
|
|
277
492
|
}) {
|
|
278
493
|
/*
|
|
279
494
|
* ๐ด ALWAYS wrapped, even with no selection and no drag.
|
|
@@ -290,8 +505,14 @@ function GalleryTile({
|
|
|
290
505
|
* handlers, so this costs nothing and cannot change behavior anywhere else.
|
|
291
506
|
*/
|
|
292
507
|
return (
|
|
293
|
-
<TileShell name={item.name} selection={selection} drag={drag}>
|
|
294
|
-
<RawGalleryTile
|
|
508
|
+
<TileShell name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
|
|
509
|
+
<RawGalleryTile
|
|
510
|
+
item={item}
|
|
511
|
+
cache={cache}
|
|
512
|
+
onOpen={onOpen}
|
|
513
|
+
autoPreview={autoPreview}
|
|
514
|
+
measure={measure}
|
|
515
|
+
/>
|
|
295
516
|
</TileShell>
|
|
296
517
|
);
|
|
297
518
|
}
|
|
@@ -353,11 +574,13 @@ function RawGalleryTile({
|
|
|
353
574
|
cache,
|
|
354
575
|
onOpen,
|
|
355
576
|
autoPreview,
|
|
577
|
+
measure,
|
|
356
578
|
}: {
|
|
357
579
|
item: MediaGalleryItem;
|
|
358
580
|
cache: MediaCache;
|
|
359
581
|
onOpen: () => void;
|
|
360
582
|
autoPreview?: boolean;
|
|
583
|
+
measure?: boolean;
|
|
361
584
|
}) {
|
|
362
585
|
const category = galleryItemCategory(item);
|
|
363
586
|
const { media } = useItemMedia(item, cache);
|
|
@@ -383,7 +606,7 @@ function RawGalleryTile({
|
|
|
383
606
|
// whose detail view is upright, which reads as the fix not having worked.
|
|
384
607
|
{...(item.orientation ? { orientation: item.orientation } : {})}
|
|
385
608
|
>
|
|
386
|
-
<GalleryMeta item={item} />
|
|
609
|
+
<GalleryMeta item={item} measure={measure} />
|
|
387
610
|
</HoverScrubVideoThumb>
|
|
388
611
|
);
|
|
389
612
|
}
|
|
@@ -430,7 +653,7 @@ function RawGalleryTile({
|
|
|
430
653
|
/>
|
|
431
654
|
</div>
|
|
432
655
|
)}
|
|
433
|
-
<GalleryMeta item={item} />
|
|
656
|
+
<GalleryMeta item={item} measure={measure} />
|
|
434
657
|
</button>
|
|
435
658
|
);
|
|
436
659
|
}
|
|
@@ -582,7 +805,7 @@ export function stagePlayableVideo(
|
|
|
582
805
|
item: MediaGalleryItem,
|
|
583
806
|
media: MediaGalleryItemMedia,
|
|
584
807
|
streamUrl: string,
|
|
585
|
-
):
|
|
808
|
+
): ChapteredVideo {
|
|
586
809
|
return {
|
|
587
810
|
id: item.id,
|
|
588
811
|
name: item.name,
|
|
@@ -602,9 +825,51 @@ export function stagePlayableVideo(
|
|
|
602
825
|
// broken one.
|
|
603
826
|
...(media.vttUrl ? { vttUrl: media.vttUrl } : {}),
|
|
604
827
|
...(media.processingStatus ? { processingStatus: media.processingStatus } : {}),
|
|
828
|
+
// The named sections, straight off the ITEM rather than out of `media`: chapters are a
|
|
829
|
+
// property of the film, not of a resolved URL, and they are the same list whichever
|
|
830
|
+
// transport the stage ends up playing. Absent when there are none, so a video with no
|
|
831
|
+
// sections hands the player nothing to draw a chapters menu for.
|
|
832
|
+
...(item.chapters && item.chapters.length > 0 ? { chapters: item.chapters } : {}),
|
|
605
833
|
};
|
|
606
834
|
}
|
|
607
835
|
|
|
836
|
+
/**
|
|
837
|
+
* ๐ด The stage's session volume, on the NATIVE transport.
|
|
838
|
+
*
|
|
839
|
+
* The Vidstack stage gets this from `VideoPlayer` (`volumeKey`, read off the player's own
|
|
840
|
+
* `volume-change`); this is the same rule for the plain `<video>` an app opts into with
|
|
841
|
+
* `nativeVideoStage`. Same {@link STAGE_VOLUME_KEY}, because it is the same surface โ the two
|
|
842
|
+
* transports are never mounted at once, and keying them apart would mean an app that switched
|
|
843
|
+
* transports lost the level mid-session for no reason a person could name.
|
|
844
|
+
*
|
|
845
|
+
* Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
|
|
846
|
+
* changing it during a session except if I refresh or first log in then continue starting it
|
|
847
|
+
* at mute. Then if I unmute it, keep that value as I change to other videos."*
|
|
848
|
+
*
|
|
849
|
+
* ๐ด The `else` branch is not a no-op: this element carries `autoPlay` and no `muted`, so the
|
|
850
|
+
* fresh-session case starts it silent โ which is both what he asked for and the only state a
|
|
851
|
+
* browser will actually auto-play. The native control bar is right there to unmute.
|
|
852
|
+
*/
|
|
853
|
+
function useStageVolume(ref: React.RefObject<HTMLVideoElement | null>): void {
|
|
854
|
+
React.useEffect(() => {
|
|
855
|
+
const el = ref.current;
|
|
856
|
+
if (!el) return;
|
|
857
|
+
const stored = readSessionVolume(STAGE_VOLUME_KEY);
|
|
858
|
+
el.volume = stored?.volume ?? 1;
|
|
859
|
+
el.muted = stored?.muted ?? true;
|
|
860
|
+
// What we just applied โ the element fires `volumechange` for it, and writing that back
|
|
861
|
+
// would mint a preference nobody set. Only what he does after this is his.
|
|
862
|
+
let applied = { volume: el.volume, muted: el.muted };
|
|
863
|
+
const onChange = () => {
|
|
864
|
+
if (el.volume === applied.volume && el.muted === applied.muted) return;
|
|
865
|
+
applied = { volume: el.volume, muted: el.muted };
|
|
866
|
+
writeSessionVolume(STAGE_VOLUME_KEY, applied);
|
|
867
|
+
};
|
|
868
|
+
el.addEventListener('volumechange', onChange);
|
|
869
|
+
return () => el.removeEventListener('volumechange', onChange);
|
|
870
|
+
}, [ref]);
|
|
871
|
+
}
|
|
872
|
+
|
|
608
873
|
/**
|
|
609
874
|
* The `nativeVideoStage` element โ a plain `<video>` with the ladder attached to it.
|
|
610
875
|
*
|
|
@@ -632,6 +897,7 @@ function NativeVideoStage({
|
|
|
632
897
|
}) {
|
|
633
898
|
const ref = React.useRef<HTMLVideoElement>(null);
|
|
634
899
|
useHlsSource(ref, { hlsUrl, streamUrl });
|
|
900
|
+
useStageVolume(ref);
|
|
635
901
|
return (
|
|
636
902
|
// biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions
|
|
637
903
|
<video
|
|
@@ -740,6 +1006,10 @@ function GalleryStage({
|
|
|
740
1006
|
<LazyVideoPlayer
|
|
741
1007
|
video={stagePlayableVideo(item, media, src)}
|
|
742
1008
|
autoPlay
|
|
1009
|
+
// ๐ด The STAGE's own volume memory, not the shared primitive's default key โ see
|
|
1010
|
+
// `playbackPreferences.ts`. It is what makes the level he set on this clip apply to
|
|
1011
|
+
// the next one, and it is per session, so a refresh is silent again.
|
|
1012
|
+
volumeKey={STAGE_VOLUME_KEY}
|
|
743
1013
|
loop={repeat}
|
|
744
1014
|
{...(repeat ? {} : { onEnded })}
|
|
745
1015
|
{...(onStep ? { onStep, canStep: canStep !== false } : {})}
|
|
@@ -1068,6 +1338,57 @@ export interface MediaGalleryProps {
|
|
|
1068
1338
|
* somebody the minutes of scrubbing that produced the list.
|
|
1069
1339
|
*/
|
|
1070
1340
|
onTrimVideo?: (item: MediaGalleryItem, cuts: VideoCut[]) => void | Promise<void>;
|
|
1341
|
+
/**
|
|
1342
|
+
* Store the named sections of a video โ YouTube chapters.
|
|
1343
|
+
*
|
|
1344
|
+
* The owner's ask, 2026-09-15: *"allow putting markers on videos at points in time and giving
|
|
1345
|
+
* them a title for the section for knowing what that part of the video is about, like youtube
|
|
1346
|
+
* chapters."* So this is a SAVE, not a request: the list applies the moment it comes back on
|
|
1347
|
+
* {@link MediaGalleryItem.chapters}, costs no encode, and touches no byte of the file.
|
|
1348
|
+
*
|
|
1349
|
+
* ๐ด Passing this is what turns authoring on, per app โ the same rule as `onTrimVideo`, and
|
|
1350
|
+
* for a different reason worth stating: chapters have somewhere to GO only in an app that
|
|
1351
|
+
* keeps a row per file it can write to. An app that merely lists someone else's videos must
|
|
1352
|
+
* not offer a control that saves into nothing, and because the editor is lazily imported, an
|
|
1353
|
+
* app that does not pass this does not ship the dialog either.
|
|
1354
|
+
*
|
|
1355
|
+
* ๐ด READING chapters needs nothing: an item that carries `chapters` shows them on the scrub
|
|
1356
|
+
* bar whether or not this is passed. The capability being gated is writing them.
|
|
1357
|
+
*
|
|
1358
|
+
* An EMPTY list is how every section is removed. Rejecting keeps the dialog open with the
|
|
1359
|
+
* names intact, because a failed request must not cost somebody the minutes of scrubbing and
|
|
1360
|
+
* naming that produced them.
|
|
1361
|
+
*/
|
|
1362
|
+
onSaveChapters?: (item: MediaGalleryItem, chapters: VideoChapter[]) => void | Promise<void>;
|
|
1363
|
+
/**
|
|
1364
|
+
* Store TAGS and a DESCRIPTION โ on the open file, or on everything that is ticked.
|
|
1365
|
+
*
|
|
1366
|
+
* The owner's ask, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an
|
|
1367
|
+
* option for apps to tag media and aadd descriptions to media. In collections allow these
|
|
1368
|
+
* features so I can tag and add descriptions to its media."* The gallery could already READ
|
|
1369
|
+
* `tags` โ the tile prints the first three and the chips filter on them โ and had no way to
|
|
1370
|
+
* write one and no `description` field at all, so an app could display a vocabulary it
|
|
1371
|
+
* already had and nothing in here could create a word of it.
|
|
1372
|
+
*
|
|
1373
|
+
* ๐ด Passing this is what turns authoring on, per app โ the same rule as `onTrimVideo`, and
|
|
1374
|
+
* for the same two reasons: an app that cannot persist a tag must not offer a control that
|
|
1375
|
+
* saves into nothing, and because the editor is lazily imported, an app that does not pass
|
|
1376
|
+
* this does not ship the dialog either. READING needs nothing, exactly as chapters do.
|
|
1377
|
+
*
|
|
1378
|
+
* ๐ด The first argument is a LIST, always โ one item is `[item]`. Tagging forty holiday
|
|
1379
|
+
* photographs at once is the case he will actually hit, and a plural verb that a consumer
|
|
1380
|
+
* can only implement by looping the singular is the shape `tasks/Completed/240-*.md`
|
|
1381
|
+
* refuses in as many words: *"One request for N ids, not N requests."* The change arrives
|
|
1382
|
+
* as `addTags`/`removeTags` rather than as a new list for exactly this reason โ forty items
|
|
1383
|
+
* do not share one tag list, so "the new tags" would wipe thirty-nine nobody looked at.
|
|
1384
|
+
* `applyMetaChange` (exported from this subpath) is the merge, so no app writes it twice.
|
|
1385
|
+
*
|
|
1386
|
+
* Rejecting keeps the dialog open with the draft intact.
|
|
1387
|
+
*/
|
|
1388
|
+
onSaveMeta?: (
|
|
1389
|
+
items: MediaGalleryItem[],
|
|
1390
|
+
change: MediaMetaChange,
|
|
1391
|
+
) => void | Promise<void>;
|
|
1071
1392
|
/**
|
|
1072
1393
|
* What happens after a trim is queued, in the app's own words โ rendered under the strip.
|
|
1073
1394
|
* Apps differ: one runs it on the spot, another drains a queue on a schedule, and "when
|
|
@@ -1081,7 +1402,7 @@ export interface MediaGalleryProps {
|
|
|
1081
1402
|
*/
|
|
1082
1403
|
allowPictureReplace?: boolean;
|
|
1083
1404
|
/**
|
|
1084
|
-
* The
|
|
1405
|
+
* The Picture control โ brightness, contrast and colour for what is on the stage. On by
|
|
1085
1406
|
* default, because it needs nothing from the app: it is a CSS filter over the stage and
|
|
1086
1407
|
* nothing is saved, so there is no capability to be missing.
|
|
1087
1408
|
*
|
|
@@ -1090,6 +1411,33 @@ export interface MediaGalleryProps {
|
|
|
1090
1411
|
* deep. Pass `false` in an app where adjusting the picture is meaningless.
|
|
1091
1412
|
*/
|
|
1092
1413
|
pictureControls?: boolean;
|
|
1414
|
+
/**
|
|
1415
|
+
* What the tiles and the table say about SIZE and LENGTH โ the one param he guessed at.
|
|
1416
|
+
*
|
|
1417
|
+
* ๐ด Two asks, one prop, because they are one surface. `ideas/collections-09-14.md`, twice:
|
|
1418
|
+
* *"In collections I want to see the exact file size in bytes as a column option so I can
|
|
1419
|
+
* easily tell if two files are the exact same byte size to help me know if they are
|
|
1420
|
+
* duplicates or not"* and *"in the grid view show the video length instead of the size."*
|
|
1421
|
+
* Then `ideas/binary-server-09-15.md` asked for both facts on the same tiles and named the
|
|
1422
|
+
* right home: *"maybe controlled through param on media gallery in cb."*
|
|
1423
|
+
*
|
|
1424
|
+
* ๐ด Everything here is OFF by default, so a consumer that passes nothing renders exactly
|
|
1425
|
+
* what it renders today โ the compatibility rule every other prop on this component holds.
|
|
1426
|
+
*/
|
|
1427
|
+
meta?: {
|
|
1428
|
+
/**
|
|
1429
|
+
* A tile's caption leads with the measurement that means something for its kind: the
|
|
1430
|
+
* RUNNING TIME on a film or a track, the size on everything else. Tags keep the line
|
|
1431
|
+
* they had and the measurement joins them on it โ see {@link galleryTileLine}.
|
|
1432
|
+
*/
|
|
1433
|
+
tileMeasure?: boolean;
|
|
1434
|
+
/**
|
|
1435
|
+
* The table offers the exact byte count as its own column, beside the rounded Size one
|
|
1436
|
+
* rather than instead of it โ see `exactBytesLabel` in `GalleryTable.tsx`. A rounded size cannot answer
|
|
1437
|
+
* *"are these two the same file"*, which is the job he named for it.
|
|
1438
|
+
*/
|
|
1439
|
+
exactBytes?: boolean;
|
|
1440
|
+
};
|
|
1093
1441
|
}
|
|
1094
1442
|
|
|
1095
1443
|
/**
|
|
@@ -1136,7 +1484,10 @@ export function MediaGallery({
|
|
|
1136
1484
|
allowPictureReplace = false,
|
|
1137
1485
|
pictureControls = true,
|
|
1138
1486
|
onTrimVideo,
|
|
1487
|
+
onSaveChapters,
|
|
1488
|
+
onSaveMeta,
|
|
1139
1489
|
trimNote,
|
|
1490
|
+
meta,
|
|
1140
1491
|
}: MediaGalleryProps) {
|
|
1141
1492
|
const [uncontrolledQuery, setUncontrolledQuery] = useState('');
|
|
1142
1493
|
/** Controlled when the consumer passes `query`, uncontrolled otherwise โ see the prop. */
|
|
@@ -1350,6 +1701,17 @@ export function MediaGallery({
|
|
|
1350
1701
|
const [turningVideo, setTurningVideo] = useState<MediaGalleryItem | null>(null);
|
|
1351
1702
|
/** The clip whose cut marks are being made, or null. See `onTrimVideo`. */
|
|
1352
1703
|
const [trimmingVideo, setTrimmingVideo] = useState<MediaGalleryItem | null>(null);
|
|
1704
|
+
/** The clip whose sections are being named, or null. See `onSaveChapters`. */
|
|
1705
|
+
const [chapteringVideo, setChapteringVideo] = useState<MediaGalleryItem | null>(null);
|
|
1706
|
+
/**
|
|
1707
|
+
* What the tag/description editor is open over, or null. See `onSaveMeta`.
|
|
1708
|
+
*
|
|
1709
|
+
* ๐ด A LIST rather than one item, because this is the one capability dialog reachable from
|
|
1710
|
+
* BOTH screens: the open file's strip passes `[selected]`, and the bulk bar passes the whole
|
|
1711
|
+
* ticked set. One piece of state for both is what keeps the two paths from becoming two
|
|
1712
|
+
* dialogs that drift โ and it is why `items` is the editor's prop rather than `item`.
|
|
1713
|
+
*/
|
|
1714
|
+
const [taggingItems, setTaggingItems] = useState<MediaGalleryItem[] | null>(null);
|
|
1353
1715
|
const [grabbing, setGrabbing] = useState<{ item: MediaGalleryItem; at: number } | null>(null);
|
|
1354
1716
|
const [showPicture, setShowPicture] = useState(false);
|
|
1355
1717
|
const [light, setLight] = useState<PictureLight>(NO_LIGHT);
|
|
@@ -1361,8 +1723,26 @@ export function MediaGallery({
|
|
|
1361
1723
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explained immediately above
|
|
1362
1724
|
useEffect(() => {
|
|
1363
1725
|
setRenaming(null);
|
|
1364
|
-
setEditingPicture(null);
|
|
1365
1726
|
setLight(NO_LIGHT);
|
|
1727
|
+
/*
|
|
1728
|
+
* ๐ด EVERY capability dialog, not just the picture editor โ the owner, 2026-09-14, on the
|
|
1729
|
+
* thumbnail chooser in `apps/collections`: *"Clicking on other folders also did not move
|
|
1730
|
+
* to view them and the thumbnail selection screen for the first video stayed on the
|
|
1731
|
+
* screen. Selecting other sidebar files and icons should also leave that screen."*
|
|
1732
|
+
*
|
|
1733
|
+
* Only `editingPicture` was cleared here, so a dialog opened against the file you WERE
|
|
1734
|
+
* looking at outlived the selection and then described the wrong file: its title, its
|
|
1735
|
+
* `src`, its `startAt` and โ the part that loses work โ the item every save handler above
|
|
1736
|
+
* is closed over. The fix is one line per dialog rather than a rule in a doc comment,
|
|
1737
|
+
* because "remember to clear yours" is exactly the instruction the four dialogs added
|
|
1738
|
+
* after this one would not have read.
|
|
1739
|
+
*/
|
|
1740
|
+
setEditingPicture(null);
|
|
1741
|
+
setTurningVideo(null);
|
|
1742
|
+
setTrimmingVideo(null);
|
|
1743
|
+
setChapteringVideo(null);
|
|
1744
|
+
setTaggingItems(null);
|
|
1745
|
+
setGrabbing(null);
|
|
1366
1746
|
}, [selectedId]);
|
|
1367
1747
|
|
|
1368
1748
|
// ๐ด Enabled whenever there is somewhere else to go, NOT only away from the edges โ the
|
|
@@ -1504,6 +1884,78 @@ export function MediaGallery({
|
|
|
1504
1884
|
el.scrollIntoView({ behavior: reduced || snapsToStage(selected) ? 'auto' : 'smooth', block: 'start' });
|
|
1505
1885
|
}, [selectedId, selected]);
|
|
1506
1886
|
|
|
1887
|
+
/**
|
|
1888
|
+
* โโ Bring the tile the stage is showing back into view โ the strip's REVEAL โโโโโโโโโโโโโ
|
|
1889
|
+
*
|
|
1890
|
+
* Owner, `ideas/collections-09-14.md`: *"I want to โฆ still see the playing video in the
|
|
1891
|
+
* list."* Keeping it IN the list (the strip above) is only half of that: on a folder of
|
|
1892
|
+
* ninety files the marked tile is forty rows away, and a mark nobody can find is a mark
|
|
1893
|
+
* nobody has. This is `file-tree/FileTree.tsx`'s reveal, applied to the same problem, and
|
|
1894
|
+
* it keeps that one's limits:
|
|
1895
|
+
*
|
|
1896
|
+
* 1. **Only when the tile is off screen.** A strip that jumps a few pixels every time you
|
|
1897
|
+
* click a tile you can already see reads as a bug.
|
|
1898
|
+
* 2. **Never mid-drag** โ vacuous here rather than skipped: the strip's tiles are handed no
|
|
1899
|
+
* `drag` prop at all (see `renderTile` below), so there is no drag of ours to be in the
|
|
1900
|
+
* middle of. `MediaGallery.spec.tsx` asserts that, so a future strip that DOES become a
|
|
1901
|
+
* drag source fails a test pointing at this note instead of silently yanking the list
|
|
1902
|
+
* out from under a pointer.
|
|
1903
|
+
*
|
|
1904
|
+
* ๐ด โฆand one limit the tree has no need of: it runs only while the stage is PINNED.
|
|
1905
|
+
*
|
|
1906
|
+
* The watch view is ONE scroller โ stage on top, strip underneath โ so scrolling the strip
|
|
1907
|
+
* scrolls the stage away. The owner has already ruled on that collision, from the other
|
|
1908
|
+
* side: *"when I scroll down through thumbnails and click one to play it then I want to
|
|
1909
|
+
* navigate automatically back to the top to see the movie I am playing"* (2026-08-14, the
|
|
1910
|
+
* effect directly above). Revealing the tile in that mode would undo, every single time,
|
|
1911
|
+
* the thing that effect exists to do โ the film would be playing off the top of the screen
|
|
1912
|
+
* again, which is the older report re-opened.
|
|
1913
|
+
*
|
|
1914
|
+
* Pinning is his own answer to it: *"a button to lock the video in place on top so I can
|
|
1915
|
+
* scroll thumbnails and still see the full video."* With the top region sticky the stage
|
|
1916
|
+
* cannot scroll away, the effect above finds it already in place and moves nothing, and the
|
|
1917
|
+
* strip's scroll is free โ so that is exactly the mode where this reveal is not taking
|
|
1918
|
+
* something away to give something else.
|
|
1919
|
+
*
|
|
1920
|
+
* ๐ด It scrolls by a computed delta rather than by `scrollIntoView`, and the sticky region
|
|
1921
|
+
* is why: `block: 'nearest'` on a tile ABOVE the fold aligns it to the top of the scroller,
|
|
1922
|
+
* which is the seventy percent of it the pinned stage is sitting on โ a "reveal" that puts
|
|
1923
|
+
* the tile behind the video. The visible band starts where the pinned region ends.
|
|
1924
|
+
*/
|
|
1925
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the TRIGGER and is deliberately not read in the body โ the tile is found through the DOM by the mark that identifies it. `pinStage` is a GUARD and not a trigger: pressing the pin would otherwise scroll the strip on its own, which is movement nobody asked for.
|
|
1926
|
+
useEffect(() => {
|
|
1927
|
+
const stage = stageRef.current;
|
|
1928
|
+
if (!selectedId || !pinStage || !stage) return;
|
|
1929
|
+
const scroller = stage.closest<HTMLElement>('.cbgd-watch');
|
|
1930
|
+
// The strip's own marked element, in whichever view is on: a tile shell in the grid, a
|
|
1931
|
+
// row in the table. Both carry `data-playing` for exactly this.
|
|
1932
|
+
const mark = scroller?.querySelector<HTMLElement>('[data-playing]');
|
|
1933
|
+
if (!scroller || !mark) return;
|
|
1934
|
+
// The table scrolls inside its own frame (`.cbgt-root`), where the stage is not even a
|
|
1935
|
+
// sibling; the tile grid windows against the watch scroller itself.
|
|
1936
|
+
const frame = mark.closest<HTMLElement>('.cbgt-root') ?? scroller;
|
|
1937
|
+
const view = frame.getBoundingClientRect();
|
|
1938
|
+
// Where the pinned region stops covering the strip. Absent (or not pinned) โ the frame's
|
|
1939
|
+
// own top edge, which is what the table's separate scroller always uses.
|
|
1940
|
+
const pinned = frame === scroller ? scroller.querySelector('.cbgd-watch-top') : null;
|
|
1941
|
+
const top = pinned ? Math.max(view.top, pinned.getBoundingClientRect().bottom) : view.top;
|
|
1942
|
+
const box = mark.getBoundingClientRect();
|
|
1943
|
+
const delta =
|
|
1944
|
+
box.top < top ? box.top - top : box.bottom > view.bottom ? box.bottom - view.bottom : 0;
|
|
1945
|
+
// Limit 1: already in the band, so nothing moves.
|
|
1946
|
+
if (delta === 0) return;
|
|
1947
|
+
frame.scrollTop += delta;
|
|
1948
|
+
}, [selectedId]);
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Is one of the capability dialogs on screen? Every one of them is a focus-trapped modal
|
|
1952
|
+
* over the gallery, so for as long as one is up the window-level shortcuts below are not
|
|
1953
|
+
* the gallery's to take โ see the guard in the effect that follows.
|
|
1954
|
+
*/
|
|
1955
|
+
const capabilityDialogOpen = Boolean(
|
|
1956
|
+
editingPicture || turningVideo || trimmingVideo || chapteringVideo || grabbing,
|
|
1957
|
+
);
|
|
1958
|
+
|
|
1507
1959
|
/**
|
|
1508
1960
|
* Arrow keys step, Escape closes.
|
|
1509
1961
|
*
|
|
@@ -1511,9 +1963,22 @@ export function MediaGallery({
|
|
|
1511
1963
|
* the thing the owner just clicked and it owns focus โ a handler on an ancestor div only
|
|
1512
1964
|
* fires when focus happens to be outside the player, which is the state a person watching a
|
|
1513
1965
|
* video is never in. The typing guard below is what makes a window binding safe.
|
|
1966
|
+
*
|
|
1967
|
+
* โโ ๐ด โฆand it stands DOWN while a capability dialog is open โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1968
|
+
* The owner, 2026-09-14, on the thumbnail chooser: *"the space bar should toggle playing
|
|
1969
|
+
* the video no matter what is focused but isn't working on the thumbnail selection page."*
|
|
1970
|
+
*
|
|
1971
|
+
* It was firing โ on the wrong film. This handler binds at CAPTURE on `window`, so a press
|
|
1972
|
+
* inside an open dialog reached it first and toggled the STAGE player behind the dialog,
|
|
1973
|
+
* which is a video the reader cannot see. `FrameGrabber` now binds the same fleet rule
|
|
1974
|
+
* (`usePlayPauseSpace`) to its own `<video>`, and two capture listeners on one window both
|
|
1975
|
+
* "working" is how a single press pauses two different films โ `stopPropagation` does not
|
|
1976
|
+
* help, because it does not stop other listeners on the same target. So the gallery yields
|
|
1977
|
+
* the key instead: whoever is on top owns it. The same guard is what stops Escape closing
|
|
1978
|
+
* the gallery's selection out from under a dialog that Radix was about to close on its own.
|
|
1514
1979
|
*/
|
|
1515
1980
|
useEffect(() => {
|
|
1516
|
-
if (!selectedId) return;
|
|
1981
|
+
if (!selectedId || capabilityDialogOpen) return;
|
|
1517
1982
|
const onKey = (event: KeyboardEvent) => {
|
|
1518
1983
|
/*
|
|
1519
1984
|
* ๐ด Space is play/pause, and it is decided BEFORE the typing guard below โ which is
|
|
@@ -1668,11 +2133,9 @@ export function MediaGallery({
|
|
|
1668
2133
|
/>
|
|
1669
2134
|
) : null}
|
|
1670
2135
|
{turningVideo ? (
|
|
1671
|
-
<
|
|
1672
|
-
|
|
1673
|
-
{
|
|
1674
|
-
name={turningVideo.name}
|
|
1675
|
-
orientation={turningVideo.orientation}
|
|
2136
|
+
<TurnVideoDialog
|
|
2137
|
+
item={turningVideo}
|
|
2138
|
+
cache={mediaCache}
|
|
1676
2139
|
onClose={() => setTurningVideo(null)}
|
|
1677
2140
|
onSave={async (next: MediaOrientation | undefined) => {
|
|
1678
2141
|
await onSaveOrientation?.(turningVideo, next);
|
|
@@ -1692,6 +2155,32 @@ export function MediaGallery({
|
|
|
1692
2155
|
}}
|
|
1693
2156
|
/>
|
|
1694
2157
|
) : null}
|
|
2158
|
+
{chapteringVideo && onSaveChapters ? (
|
|
2159
|
+
<LazyVideoChapterEditor
|
|
2160
|
+
src={chapteringVideo.streamUrl ?? chapteringVideo.url ?? ''}
|
|
2161
|
+
{...(chapteringVideo.posterUrl ? { posterUrl: chapteringVideo.posterUrl } : {})}
|
|
2162
|
+
name={chapteringVideo.name}
|
|
2163
|
+
durationSeconds={chapteringVideo.durationSeconds ?? null}
|
|
2164
|
+
{...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {})}
|
|
2165
|
+
onClose={() => setChapteringVideo(null)}
|
|
2166
|
+
onSubmit={async (chapters: VideoChapter[]) => {
|
|
2167
|
+
await onSaveChapters(chapteringVideo, chapters);
|
|
2168
|
+
}}
|
|
2169
|
+
/>
|
|
2170
|
+
) : null}
|
|
2171
|
+
{taggingItems && taggingItems.length > 0 && onSaveMeta ? (
|
|
2172
|
+
<LazyMediaMetaEditor
|
|
2173
|
+
items={taggingItems}
|
|
2174
|
+
// The album's own vocabulary, which is already computed for the filter chips โ
|
|
2175
|
+
// offering it is what stops `holiday`, `Holiday` and `holidays` being minted as
|
|
2176
|
+
// three chips that each find a third of the set.
|
|
2177
|
+
suggestions={tags}
|
|
2178
|
+
onClose={() => setTaggingItems(null)}
|
|
2179
|
+
onSubmit={async (change: MediaMetaChange) => {
|
|
2180
|
+
await onSaveMeta(taggingItems, change);
|
|
2181
|
+
}}
|
|
2182
|
+
/>
|
|
2183
|
+
) : null}
|
|
1695
2184
|
{grabbing ? (
|
|
1696
2185
|
<LazyFrameGrabber
|
|
1697
2186
|
src={grabbing.item.streamUrl ?? grabbing.item.url ?? ''}
|
|
@@ -1718,8 +2207,37 @@ export function MediaGallery({
|
|
|
1718
2207
|
|
|
1719
2208
|
if (selected) {
|
|
1720
2209
|
const category = galleryItemCategory(selected);
|
|
1721
|
-
|
|
2210
|
+
/*
|
|
2211
|
+
* โโ ๐ด The strip under the stage is EVERY displayed item, the open one included โโโโโโโ
|
|
2212
|
+
*
|
|
2213
|
+
* Owner, `ideas/collections-09-14.md`: *"sometimes I click on a video and while it's
|
|
2214
|
+
* playing I click on a different video in the thumbnails under the playing video and the
|
|
2215
|
+
* video I click on gets taken out of the list. I want to change this cb media gallery
|
|
2216
|
+
* behavior so that I can still see the playing video in the list and nothing about the
|
|
2217
|
+
* list changes as I pick other videos."*
|
|
2218
|
+
*
|
|
2219
|
+
* This line was `displayItems.filter((i) => i.id !== selected.id)` โ the strip was
|
|
2220
|
+
* everything EXCEPT what was playing. So every pick removed one item and put the previous
|
|
2221
|
+
* one back, and because the two sit at different places in the sort, every tile between
|
|
2222
|
+
* them shifted by one: the list re-sorted itself under his pointer on every click, which
|
|
2223
|
+
* is what *"the video I click on gets taken out of the list"* is describing. His
|
|
2224
|
+
* requirement is stated twice and is the exact opposite, so there is no filter here at
|
|
2225
|
+
* all now โ the strip is `displayItems`, in `displayItems`' order, and the open one is
|
|
2226
|
+
* MARKED (`stageMark` below) instead of removed.
|
|
2227
|
+
*
|
|
2228
|
+
* ๐ด Nothing downstream ever depended on the exclusion. `step`/โง-arrows and `advance`
|
|
2229
|
+
* have always run over `displayIds` โ the unfiltered displayed order โ so their
|
|
2230
|
+
* arithmetic is untouched by this, and `nextInRun` already refuses to draw the current
|
|
2231
|
+
* item as the next one (`playbackRun.ts`, and `MediaGallery.spec.tsx` asserts it from
|
|
2232
|
+
* this edge). What DID depend on it was the heading, which said *"More files"*; with the
|
|
2233
|
+
* open file in the list that word is no longer true, so it is gone too.
|
|
2234
|
+
*/
|
|
2235
|
+
const stageMark =
|
|
2236
|
+
category === 'video' || category === 'audio' ? 'Now playing' : 'On screen';
|
|
1722
2237
|
const downloadHref = selected.downloadUrl ?? selected.url;
|
|
2238
|
+
// Capitalised so JSX reads it as a component rather than an HTML tag โ see
|
|
2239
|
+
// `PLAYBACK_END_ICONS` for why the setting gets five glyphs and not one.
|
|
2240
|
+
const PlaybackEndIcon = PLAYBACK_END_ICONS[playbackEnd];
|
|
1723
2241
|
return (
|
|
1724
2242
|
<>
|
|
1725
2243
|
{capabilityDialogs}
|
|
@@ -1736,16 +2254,19 @@ export function MediaGallery({
|
|
|
1736
2254
|
// The date joins the line rather than replacing what was there โ beside the title is
|
|
1737
2255
|
// exactly where the owner asked for it, and the titlebar has the room a tile caption
|
|
1738
2256
|
// does not.
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
2257
|
+
stageMeta(
|
|
2258
|
+
[
|
|
2259
|
+
selected.tags && selected.tags.length > 0
|
|
2260
|
+
? selected.tags.join(' ยท ')
|
|
2261
|
+
: selected.sizeBytes != null
|
|
2262
|
+
? formatBytes(selected.sizeBytes)
|
|
2263
|
+
: null,
|
|
2264
|
+
createdLabel(selected.createdAt),
|
|
2265
|
+
]
|
|
2266
|
+
.filter(Boolean)
|
|
2267
|
+
.join(' ยท ') || undefined,
|
|
2268
|
+
selected.description,
|
|
2269
|
+
)
|
|
1749
2270
|
}
|
|
1750
2271
|
sticky={pinStage}
|
|
1751
2272
|
stage={
|
|
@@ -1802,6 +2323,11 @@ export function MediaGallery({
|
|
|
1802
2323
|
Owner, 2026-08-14: "a button to lock the video in place on top so I can scroll
|
|
1803
2324
|
thumbnails and still see the full video." Shown for every kind โ browsing a
|
|
1804
2325
|
folder while keeping a photograph on screen is the same need. */}
|
|
2326
|
+
{/* ๐ด `Pin` vs `Pinned` was a READOUT, and one glyph cannot be both. It moves into
|
|
2327
|
+
three places at once: the lit pill (`is-active`, which this control never had
|
|
2328
|
+
โ the word was carrying the whole state on its own), `aria-pressed`, and the
|
|
2329
|
+
accessible name, which says which way the press goes. The tooltip below
|
|
2330
|
+
already named both states and is unchanged. */}
|
|
1805
2331
|
<Tooltip content={
|
|
1806
2332
|
pinStage
|
|
1807
2333
|
? 'Unpin โ let the stage scroll away with the page'
|
|
@@ -1810,15 +2336,16 @@ export function MediaGallery({
|
|
|
1810
2336
|
<button
|
|
1811
2337
|
// guardrails-ignore no-raw-action-button: see the previous button.
|
|
1812
2338
|
type='button'
|
|
1813
|
-
className=
|
|
2339
|
+
className={`cbgd-order-btn is-icon${pinStage ? ' is-active' : ''}`}
|
|
1814
2340
|
aria-pressed={pinStage}
|
|
2341
|
+
aria-label={pinStage ? 'Pinned โ unpin the stage' : 'Pin the stage'}
|
|
1815
2342
|
onClick={() => {
|
|
1816
2343
|
const next = !pinStage;
|
|
1817
2344
|
setPinStage(next);
|
|
1818
2345
|
writePinStage(next);
|
|
1819
2346
|
}}
|
|
1820
2347
|
>
|
|
1821
|
-
|
|
2348
|
+
<Pin aria-hidden />
|
|
1822
2349
|
</button>
|
|
1823
2350
|
</Tooltip>
|
|
1824
2351
|
{/* ๐ด Shown for EVERY kind, not only video. Three of the five settings run the
|
|
@@ -1826,11 +2353,18 @@ export function MediaGallery({
|
|
|
1826
2353
|
gating this on `category === 'video'` meant the owner could not start one from
|
|
1827
2354
|
a picture, which is the item most folders open on. `playbackPreferences.ts`
|
|
1828
2355
|
explains why five settings on one button rather than a row of toggles. */}
|
|
1829
|
-
|
|
2356
|
+
{/* ๐ด The tooltip LEADS with the current value now. It used to carry the hint
|
|
2357
|
+
alone, which was enough while the label was printed on the pill beside it;
|
|
2358
|
+
with the word gone, a hover that explains what the setting does without
|
|
2359
|
+
saying which one is set has dropped the half he was reading. The
|
|
2360
|
+
`aria-label` is unchanged โ it always carried the value in full. */}
|
|
2361
|
+
<Tooltip
|
|
2362
|
+
content={`${PLAYBACK_END_LABELS[playbackEnd].label} โ ${PLAYBACK_END_LABELS[playbackEnd].hint}`}
|
|
2363
|
+
>
|
|
1830
2364
|
<button
|
|
1831
2365
|
// guardrails-ignore no-raw-action-button: see the previous button.
|
|
1832
2366
|
type='button'
|
|
1833
|
-
className={`cbgd-order-btn${playbackEnd === 'stop' ? '' : ' is-active'}`}
|
|
2367
|
+
className={`cbgd-order-btn is-icon${playbackEnd === 'stop' ? '' : ' is-active'}`}
|
|
1834
2368
|
onClick={() => {
|
|
1835
2369
|
const next = nextPlaybackEnd(playbackEnd);
|
|
1836
2370
|
setPlaybackEnd(next);
|
|
@@ -1838,7 +2372,7 @@ export function MediaGallery({
|
|
|
1838
2372
|
}}
|
|
1839
2373
|
aria-label={`When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`}
|
|
1840
2374
|
>
|
|
1841
|
-
|
|
2375
|
+
<PlaybackEndIcon aria-hidden />
|
|
1842
2376
|
</button>
|
|
1843
2377
|
</Tooltip>
|
|
1844
2378
|
{/* The slideshow speed, and ONLY while a setting that advances is chosen โ a
|
|
@@ -1847,7 +2381,13 @@ export function MediaGallery({
|
|
|
1847
2381
|
{runAdvances(playbackEnd) ? (
|
|
1848
2382
|
<Tooltip content='How long each picture holds the screen before the next item'>
|
|
1849
2383
|
<label className='cbgd-dwell'>
|
|
1850
|
-
|
|
2384
|
+
{/* The word `Hold` was the control's NAME and is now the hourglass; the
|
|
2385
|
+
`5s` beside it is its VALUE and stays printed, because a duration you
|
|
2386
|
+
have to hover to read is a duration you cannot compare. The select
|
|
2387
|
+
keeps its own `aria-label`, so the glyph is decoration. */}
|
|
2388
|
+
<span className='cbgd-dwell-label'>
|
|
2389
|
+
<Timer aria-hidden />
|
|
2390
|
+
</span>
|
|
1851
2391
|
<select
|
|
1852
2392
|
// guardrails-ignore no-raw-inputs: `.cbgd-dwell` is this module's own
|
|
1853
2393
|
// self-contained plain-CSS control, for the same reason `.cbgd-search`
|
|
@@ -1927,17 +2467,43 @@ export function MediaGallery({
|
|
|
1927
2467
|
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
1928
2468
|
// gallery's own plain CSS, matching its neighbors.
|
|
1929
2469
|
type='button'
|
|
1930
|
-
className='cbgd-order-btn'
|
|
2470
|
+
className='cbgd-order-btn is-icon'
|
|
2471
|
+
aria-label='Rename'
|
|
1931
2472
|
onClick={() => {
|
|
1932
2473
|
setRenameDraft(selected.name);
|
|
1933
2474
|
setRenaming(selected.id);
|
|
1934
2475
|
}}
|
|
1935
2476
|
>
|
|
1936
|
-
|
|
2477
|
+
<Pencil aria-hidden />
|
|
1937
2478
|
</button>
|
|
1938
2479
|
</Tooltip>
|
|
1939
2480
|
)
|
|
1940
2481
|
) : null}
|
|
2482
|
+
{/* Rename's neighbour, and for the same reason: both are about what this file IS
|
|
2483
|
+
rather than about the picture on the stage. Offered on EVERY kind โ a PDF and a
|
|
2484
|
+
spreadsheet are exactly as worth tagging as a photograph, which is the whole
|
|
2485
|
+
point of a gallery that holds any binary. See `onSaveMeta`. */}
|
|
2486
|
+
{onSaveMeta ? (
|
|
2487
|
+
<Tooltip content='Tag this file and say what it is'>
|
|
2488
|
+
<button
|
|
2489
|
+
// guardrails-ignore no-raw-action-button: see above.
|
|
2490
|
+
type='button'
|
|
2491
|
+
className={`cbgd-order-btn is-icon${
|
|
2492
|
+
(selected.tags && selected.tags.length > 0) || selected.description
|
|
2493
|
+
? ' is-active'
|
|
2494
|
+
: ''
|
|
2495
|
+
}`}
|
|
2496
|
+
/* Lit when the file already carries one, which is the readout the strip
|
|
2497
|
+
would otherwise have no way to give: "has this been described yet" is the
|
|
2498
|
+
question somebody browsing an untagged album is actually asking, and it
|
|
2499
|
+
is invisible from the stage once the meta line scrolls past. */
|
|
2500
|
+
aria-label='Tags and description'
|
|
2501
|
+
onClick={() => setTaggingItems([selected])}
|
|
2502
|
+
>
|
|
2503
|
+
<Tags aria-hidden />
|
|
2504
|
+
</button>
|
|
2505
|
+
</Tooltip>
|
|
2506
|
+
) : null}
|
|
1941
2507
|
{/* Only a video has frames to choose between. An image IS its own thumbnail, and
|
|
1942
2508
|
offering the picker on one would be a control with nothing to pick. */}
|
|
1943
2509
|
{(onSaveThumbnail || onSaveSnapshot) && category === 'video' ? (
|
|
@@ -1949,7 +2515,19 @@ export function MediaGallery({
|
|
|
1949
2515
|
<button
|
|
1950
2516
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1951
2517
|
type='button'
|
|
1952
|
-
className='cbgd-order-btn'
|
|
2518
|
+
className='cbgd-order-btn is-icon'
|
|
2519
|
+
/* ๐ด The three words this pill used to print, kept verbatim as its name โ
|
|
2520
|
+
they are not interchangeable. "Change" vs "Choose" says whether a poster
|
|
2521
|
+
already exists, and "Take a picture" is a DIFFERENT capability (the app
|
|
2522
|
+
wired `onSaveSnapshot` and not `onSaveThumbnail`), which is why the glyph
|
|
2523
|
+
changes with it: a camera, not a picture frame. */
|
|
2524
|
+
aria-label={
|
|
2525
|
+
onSaveThumbnail
|
|
2526
|
+
? selected.posterUrl
|
|
2527
|
+
? 'Change thumbnail'
|
|
2528
|
+
: 'Choose thumbnail'
|
|
2529
|
+
: 'Take a picture'
|
|
2530
|
+
}
|
|
1953
2531
|
onClick={() => {
|
|
1954
2532
|
/*
|
|
1955
2533
|
* ๐ด Carry the PLAYHEAD across, read off the DOM rather than plumbed through.
|
|
@@ -1966,11 +2544,7 @@ export function MediaGallery({
|
|
|
1966
2544
|
setGrabbing({ item: selected, at });
|
|
1967
2545
|
}}
|
|
1968
2546
|
>
|
|
1969
|
-
{onSaveThumbnail
|
|
1970
|
-
? selected.posterUrl
|
|
1971
|
-
? '๐ผ Change thumbnail'
|
|
1972
|
-
: '๐ผ Choose thumbnail'
|
|
1973
|
-
: '๐ท Take a picture'}
|
|
2547
|
+
{onSaveThumbnail ? <ImageIcon aria-hidden /> : <Camera aria-hidden />}
|
|
1974
2548
|
</button>
|
|
1975
2549
|
</Tooltip>
|
|
1976
2550
|
) : null}
|
|
@@ -1979,12 +2553,17 @@ export function MediaGallery({
|
|
|
1979
2553
|
<button
|
|
1980
2554
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1981
2555
|
type='button'
|
|
1982
|
-
className='cbgd-order-btn'
|
|
2556
|
+
className='cbgd-order-btn is-icon'
|
|
2557
|
+
aria-label='Edit picture'
|
|
1983
2558
|
onClick={() => setEditingPicture(selected)}
|
|
1984
2559
|
// The commonest press by far is one turn on a scan or a phone photograph stored
|
|
1985
2560
|
// a quarter turn from how it should read, which is why the tooltip leads on it.
|
|
1986
2561
|
>
|
|
1987
|
-
|
|
2562
|
+
{/* A crop frame rather than the `โณ` this printed: the pill opens the whole
|
|
2563
|
+
picture editor (turn, mirror, crop, re-light), and a rotate arrow here
|
|
2564
|
+
would be the same glyph as the video's turn button one row down while
|
|
2565
|
+
meaning something four times larger. */}
|
|
2566
|
+
<Crop aria-hidden />
|
|
1988
2567
|
</button>
|
|
1989
2568
|
</Tooltip>
|
|
1990
2569
|
) : null}
|
|
@@ -1996,10 +2575,11 @@ export function MediaGallery({
|
|
|
1996
2575
|
<button
|
|
1997
2576
|
// guardrails-ignore no-raw-action-button: see above.
|
|
1998
2577
|
type='button'
|
|
1999
|
-
className='cbgd-order-btn'
|
|
2578
|
+
className='cbgd-order-btn is-icon'
|
|
2579
|
+
aria-label='Turn video'
|
|
2000
2580
|
onClick={() => setTurningVideo(selected)}
|
|
2001
2581
|
>
|
|
2002
|
-
|
|
2582
|
+
<RotateCw aria-hidden />
|
|
2003
2583
|
</button>
|
|
2004
2584
|
</Tooltip>
|
|
2005
2585
|
) : null}
|
|
@@ -2008,10 +2588,27 @@ export function MediaGallery({
|
|
|
2008
2588
|
<button
|
|
2009
2589
|
// guardrails-ignore no-raw-action-button: see above.
|
|
2010
2590
|
type='button'
|
|
2011
|
-
className='cbgd-order-btn'
|
|
2591
|
+
className='cbgd-order-btn is-icon'
|
|
2592
|
+
aria-label='Trim'
|
|
2012
2593
|
onClick={() => setTrimmingVideo(selected)}
|
|
2013
2594
|
>
|
|
2014
|
-
|
|
2595
|
+
<Scissors aria-hidden />
|
|
2596
|
+
</button>
|
|
2597
|
+
</Tooltip>
|
|
2598
|
+
) : null}
|
|
2599
|
+
{/* Trim's neighbour, and its opposite: the scissors mark what goes, this marks
|
|
2600
|
+
what a part IS and keeps it. Offered on video only, and only where the app can
|
|
2601
|
+
store the answer โ see `onSaveChapters`. */}
|
|
2602
|
+
{onSaveChapters && category === 'video' ? (
|
|
2603
|
+
<Tooltip content='Name the parts of this film โ the player shows them on the scrub bar'>
|
|
2604
|
+
<button
|
|
2605
|
+
// guardrails-ignore no-raw-action-button: see above.
|
|
2606
|
+
type='button'
|
|
2607
|
+
className='cbgd-order-btn is-icon'
|
|
2608
|
+
aria-label='Sections'
|
|
2609
|
+
onClick={() => setChapteringVideo(selected)}
|
|
2610
|
+
>
|
|
2611
|
+
<ListVideo aria-hidden />
|
|
2015
2612
|
</button>
|
|
2016
2613
|
</Tooltip>
|
|
2017
2614
|
) : null}
|
|
@@ -2020,13 +2617,21 @@ export function MediaGallery({
|
|
|
2020
2617
|
<button
|
|
2021
2618
|
// guardrails-ignore no-raw-action-button: see above.
|
|
2022
2619
|
type='button'
|
|
2023
|
-
className={`cbgd-order-btn${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`}
|
|
2620
|
+
className={`cbgd-order-btn is-icon${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`}
|
|
2024
2621
|
aria-pressed={showPicture}
|
|
2622
|
+
/* ๐ด The trailing `ยท` this used to print was the one thing on the pill that
|
|
2623
|
+
was not a name: it meant "you have already moved these sliders", which is
|
|
2624
|
+
the difference between a stage that looks wrong and a stage you made look
|
|
2625
|
+
that way. The dot has nowhere to go on a bare glyph, so it becomes a word
|
|
2626
|
+
in the accessible name โ the lit pill already says it to the eye, because
|
|
2627
|
+
`is-active` is held whenever the light is adjusted even with the sliders
|
|
2628
|
+
put away. */
|
|
2629
|
+
aria-label={isLightAdjusted(light) ? 'Picture โ adjusted' : 'Picture'}
|
|
2025
2630
|
onClick={() => setShowPicture((on) => !on)}
|
|
2026
2631
|
// Offered for pictures as well as video: a dark photograph is exactly as hard
|
|
2027
2632
|
// to look at as a dark clip, and the stage renders both.
|
|
2028
2633
|
>
|
|
2029
|
-
|
|
2634
|
+
<Sun aria-hidden />
|
|
2030
2635
|
</button>
|
|
2031
2636
|
</Tooltip>
|
|
2032
2637
|
) : null}
|
|
@@ -2072,39 +2677,58 @@ export function MediaGallery({
|
|
|
2072
2677
|
{downloadHref ? (
|
|
2073
2678
|
<Tooltip content={`Download ${selected.name}`}>
|
|
2074
2679
|
<a
|
|
2075
|
-
className='cbgd-order-btn'
|
|
2680
|
+
className='cbgd-order-btn is-icon'
|
|
2076
2681
|
href={downloadHref}
|
|
2077
2682
|
download={selected.name}
|
|
2683
|
+
// A link, so it has no `aria-pressed` to carry state and needs none โ but it
|
|
2684
|
+
// does need a name, and the file it fetches is the interesting half.
|
|
2685
|
+
aria-label={`Download ${selected.name}`}
|
|
2078
2686
|
>
|
|
2079
|
-
|
|
2687
|
+
<Download aria-hidden />
|
|
2080
2688
|
</a>
|
|
2081
2689
|
</Tooltip>
|
|
2082
2690
|
) : null}
|
|
2083
2691
|
{onDelete ? (
|
|
2692
|
+
/* `iconOnly` is `DeleteButton`'s OWN prop, not a second icon convention
|
|
2693
|
+
invented here โ it draws the trash glyph and keeps the confirmation gate
|
|
2694
|
+
that is the component's whole reason to exist. `aria-label` is explicit
|
|
2695
|
+
because the children it would otherwise name itself from are gone. */
|
|
2084
2696
|
<DeleteButton
|
|
2697
|
+
iconOnly
|
|
2698
|
+
/* Not the bare word "Delete": that is the CONFIRMATION dialog's button, and
|
|
2699
|
+
two controls with one name is a query โ a person's or a test's โ that
|
|
2700
|
+
cannot say which it means. */
|
|
2701
|
+
aria-label='Delete this item'
|
|
2085
2702
|
itemLabel={CATEGORY_LABEL[category].toLowerCase()}
|
|
2086
2703
|
tooltip='Delete this item'
|
|
2087
2704
|
onDelete={async () => {
|
|
2088
2705
|
await onDelete(selected);
|
|
2089
2706
|
select(null);
|
|
2090
2707
|
}}
|
|
2091
|
-
|
|
2092
|
-
โ Delete
|
|
2093
|
-
</DeleteButton>
|
|
2708
|
+
/>
|
|
2094
2709
|
) : null}
|
|
2095
|
-
<
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2710
|
+
<Tooltip content='Close this file and go back to the grid'>
|
|
2711
|
+
<button
|
|
2712
|
+
// guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
|
|
2713
|
+
// the dense gallery's own plain CSS (plugin-media-gallery precedent).
|
|
2714
|
+
type='button'
|
|
2715
|
+
className='cbgd-order-btn is-icon'
|
|
2716
|
+
aria-label='Back to grid'
|
|
2717
|
+
onClick={() => select(null)}
|
|
2718
|
+
>
|
|
2719
|
+
{/* A back ARROW, not a grid glyph: the grid icon is taken by the view
|
|
2720
|
+
toggle in the header, and two pills drawing the same square while one
|
|
2721
|
+
changes the layout and the other leaves the screen is the ambiguity an
|
|
2722
|
+
icon-only strip is most likely to introduce. */}
|
|
2723
|
+
<ArrowLeft aria-hidden />
|
|
2724
|
+
</button>
|
|
2725
|
+
</Tooltip>
|
|
2104
2726
|
</>
|
|
2105
2727
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2728
|
+
/* Not "More files" โ the strip holds the open file too now, so "more" would be the
|
|
2729
|
+
heading contradicting the list under it. */
|
|
2730
|
+
relatedLabel='Files'
|
|
2731
|
+
itemCount={displayItems.length}
|
|
2108
2732
|
loading={loading}
|
|
2109
2733
|
onNearEnd={onNearEnd}
|
|
2110
2734
|
minColumnWidth={minColumnWidth}
|
|
@@ -2114,14 +2738,17 @@ export function MediaGallery({
|
|
|
2114
2738
|
* Owner: *"toggling to rows from thumbnails did not work in the gallery with a video
|
|
2115
2739
|
* active."* The detail view drew tiles unconditionally, so the control was live, the
|
|
2116
2740
|
* preference was written, and nothing changed โ a pressed button doing provably
|
|
2117
|
-
* nothing. It is the same `GalleryTable` the browse view uses, over the same
|
|
2118
|
-
*
|
|
2741
|
+
* nothing. It is the same `GalleryTable` the browse view uses, over the same items the
|
|
2742
|
+
* tiles would have been, so the two screens cannot drift into two tables.
|
|
2119
2743
|
*/
|
|
2120
2744
|
{...(viewMode === 'table'
|
|
2121
2745
|
? {
|
|
2122
2746
|
relatedBody: (
|
|
2123
2747
|
<GalleryTable
|
|
2124
|
-
items={
|
|
2748
|
+
items={displayItems}
|
|
2749
|
+
// The open file is in this list exactly as it is in the tile strip, so it is
|
|
2750
|
+
// marked here exactly as it is there.
|
|
2751
|
+
playing={{ id: selected.id, label: stageMark }}
|
|
2125
2752
|
cache={mediaCache}
|
|
2126
2753
|
order={order}
|
|
2127
2754
|
onOrderChange={setOrder}
|
|
@@ -2134,12 +2761,16 @@ export function MediaGallery({
|
|
|
2134
2761
|
table as the browse view's โ so leaving it out here would fix the toggle on
|
|
2135
2762
|
one of the two screens it is wrong on. */
|
|
2136
2763
|
autoPreview={thumbAutoplay}
|
|
2764
|
+
/* Same argument for the exact-bytes column: this IS the browse view's
|
|
2765
|
+
table, so a column it offered and this one withheld would be the
|
|
2766
|
+
feature vanishing the moment you opened a file. */
|
|
2767
|
+
exactBytes={meta?.exactBytes === true}
|
|
2137
2768
|
/>
|
|
2138
2769
|
),
|
|
2139
2770
|
}
|
|
2140
2771
|
: {})}
|
|
2141
2772
|
renderTile={(index) => {
|
|
2142
|
-
const item =
|
|
2773
|
+
const item = displayItems[index];
|
|
2143
2774
|
return item ? (
|
|
2144
2775
|
<GalleryTile
|
|
2145
2776
|
key={item.id}
|
|
@@ -2149,6 +2780,14 @@ export function MediaGallery({
|
|
|
2149
2780
|
// The toggle means what it says on both screens โ "Preview all" that stopped
|
|
2150
2781
|
// applying the moment you opened something would be a different feature.
|
|
2151
2782
|
autoPreview={thumbAutoplay}
|
|
2783
|
+
// โฆand so does the running time on a film's caption: the strip under an open
|
|
2784
|
+
// file is the same grid, and a tile that told you its length on one screen and
|
|
2785
|
+
// its size on the other would be two tiles.
|
|
2786
|
+
measure={meta?.tileMeasure === true}
|
|
2787
|
+
// The one tile the stage is showing. Clicking it re-selects what is already
|
|
2788
|
+
// open, which `select` makes a no-op change โ it stays a live tile rather than
|
|
2789
|
+
// a dead one, because a tile you cannot press reads as broken.
|
|
2790
|
+
{...(item.id === selected.id ? { stageMark } : {})}
|
|
2152
2791
|
/>
|
|
2153
2792
|
) : null;
|
|
2154
2793
|
}}
|
|
@@ -2158,6 +2797,14 @@ export function MediaGallery({
|
|
|
2158
2797
|
}
|
|
2159
2798
|
|
|
2160
2799
|
return (
|
|
2800
|
+
<>
|
|
2801
|
+
{/* ๐ด The SAME dialog host as the detail view's, because exactly one capability is
|
|
2802
|
+
reachable from the browse screen: tagging a ticked selection (`onSaveMeta`). Every
|
|
2803
|
+
other dialog here is scoped to an open file and is null on this screen by
|
|
2804
|
+
construction, so rendering the one host in both places is what stops the bulk editor
|
|
2805
|
+
needing a second mount point that the `selectedId` reset would then have to know
|
|
2806
|
+
about separately. */}
|
|
2807
|
+
{capabilityDialogs}
|
|
2161
2808
|
<DenseGalleryShell<MediaGalleryOrder>
|
|
2162
2809
|
title={title}
|
|
2163
2810
|
order={{
|
|
@@ -2182,10 +2829,10 @@ export function MediaGallery({
|
|
|
2182
2829
|
<fieldset className='cbgd-order' aria-label='How files are shown'>
|
|
2183
2830
|
{(
|
|
2184
2831
|
[
|
|
2185
|
-
{ mode: 'grid',
|
|
2832
|
+
{ mode: 'grid', Icon: LayoutGrid, label: 'Grid', hint: 'Show files as thumbnails' },
|
|
2186
2833
|
{
|
|
2187
2834
|
mode: 'table',
|
|
2188
|
-
|
|
2835
|
+
Icon: Rows3,
|
|
2189
2836
|
label: 'Table',
|
|
2190
2837
|
hint: 'Show files as rows with size, length and date',
|
|
2191
2838
|
},
|
|
@@ -2196,11 +2843,15 @@ export function MediaGallery({
|
|
|
2196
2843
|
// guardrails-ignore no-raw-action-button: header pill styled by the dense
|
|
2197
2844
|
// gallery's own plain CSS, matching its sort neighbours.
|
|
2198
2845
|
type='button'
|
|
2199
|
-
className={`cbgd-order-btn${viewMode === option.mode ? ' is-active' : ''}`}
|
|
2846
|
+
className={`cbgd-order-btn is-icon${viewMode === option.mode ? ' is-active' : ''}`}
|
|
2200
2847
|
aria-pressed={viewMode === option.mode}
|
|
2848
|
+
/* The pair still says which one is live WITHOUT a word, because it is a
|
|
2849
|
+
pair: one pill is lit and the other is not, which is the whole argument
|
|
2850
|
+
for two pills rather than one toggle (see the note above). */
|
|
2851
|
+
aria-label={option.label}
|
|
2201
2852
|
onClick={() => setViewMode(option.mode)}
|
|
2202
2853
|
>
|
|
2203
|
-
<
|
|
2854
|
+
<option.Icon aria-hidden />
|
|
2204
2855
|
</button>
|
|
2205
2856
|
</Tooltip>
|
|
2206
2857
|
))}
|
|
@@ -2231,11 +2882,12 @@ export function MediaGallery({
|
|
|
2231
2882
|
// guardrails-ignore no-raw-action-button: header pill styled by the dense
|
|
2232
2883
|
// gallery's own plain CSS, matching its sort neighbours.
|
|
2233
2884
|
type='button'
|
|
2234
|
-
className={`cbgd-order-btn${thumbAutoplay ? ' is-active' : ''}`}
|
|
2885
|
+
className={`cbgd-order-btn is-icon${thumbAutoplay ? ' is-active' : ''}`}
|
|
2235
2886
|
aria-pressed={thumbAutoplay}
|
|
2887
|
+
aria-label='Preview all'
|
|
2236
2888
|
onClick={() => setThumbAutoplay(!thumbAutoplay)}
|
|
2237
2889
|
>
|
|
2238
|
-
|
|
2890
|
+
<Play aria-hidden />
|
|
2239
2891
|
</button>
|
|
2240
2892
|
</Tooltip>
|
|
2241
2893
|
) : null}
|
|
@@ -2274,6 +2926,11 @@ export function MediaGallery({
|
|
|
2274
2926
|
}
|
|
2275
2927
|
: undefined
|
|
2276
2928
|
}
|
|
2929
|
+
/* ๐ด The SAME handler and the same dialog the open file's strip uses โ it is
|
|
2930
|
+
handed a list either way, so there is no singular path for a plural gesture to
|
|
2931
|
+
drift away from. Tagging forty holiday photographs one at a time is the case
|
|
2932
|
+
the owner will actually hit, and it is one call carrying forty ids. */
|
|
2933
|
+
onTag={onSaveMeta ? () => setTaggingItems(checkedItems) : undefined}
|
|
2277
2934
|
extra={bulkActions?.(checkedItems, checks.clear)}
|
|
2278
2935
|
/>
|
|
2279
2936
|
) : null
|
|
@@ -2300,6 +2957,7 @@ export function MediaGallery({
|
|
|
2300
2957
|
/* See the related table in the detail view above โ "Preview all" has to mean the
|
|
2301
2958
|
same thing in both views or it is two features wearing one label. */
|
|
2302
2959
|
autoPreview={thumbAutoplay}
|
|
2960
|
+
exactBytes={meta?.exactBytes === true}
|
|
2303
2961
|
{...(selectable
|
|
2304
2962
|
? {
|
|
2305
2963
|
selection: (item: MediaGalleryItem) => ({
|
|
@@ -2323,15 +2981,43 @@ export function MediaGallery({
|
|
|
2323
2981
|
selection={tileSelection(item)}
|
|
2324
2982
|
drag={tileDrag(item)}
|
|
2325
2983
|
autoPreview={thumbAutoplay}
|
|
2984
|
+
measure={meta?.tileMeasure === true}
|
|
2326
2985
|
/>
|
|
2327
2986
|
) : null;
|
|
2328
2987
|
}}
|
|
2329
2988
|
/>
|
|
2989
|
+
</>
|
|
2330
2990
|
);
|
|
2331
2991
|
}
|
|
2332
2992
|
|
|
2333
2993
|
const isItem = (i: MediaGalleryItem | undefined): i is MediaGalleryItem => i !== undefined;
|
|
2334
2994
|
|
|
2995
|
+
/**
|
|
2996
|
+
* The titlebar's meta slot โ the one-line facts, plus the DESCRIPTION under them.
|
|
2997
|
+
*
|
|
2998
|
+
* ๐ด The description gets its OWN element rather than joining the ` ยท ` line, and that is a
|
|
2999
|
+
* layout fact rather than a preference: `.cbgd-meta-line` is `white-space: nowrap` with an
|
|
3000
|
+
* ellipsis, because the same class captions every tile in the grid. A sentence appended to
|
|
3001
|
+
* that line is a sentence nobody can read past its first few words โ the exact failure the
|
|
3002
|
+
* task warns about for the TILE caption, one element further up.
|
|
3003
|
+
*
|
|
3004
|
+
* So the slot becomes two children of the titlebar's own wrapping flex row: the facts stay on
|
|
3005
|
+
* the nowrap line they were always on, and `.cbmg-stage-description` is a block that wraps and
|
|
3006
|
+
* is width-capped for readability (`mediaGallery.css`). An item with no description renders
|
|
3007
|
+
* byte-for-byte what it rendered before โ `undefined` in, `undefined` out โ which is what
|
|
3008
|
+
* keeps this from restyling every gallery in the fleet.
|
|
3009
|
+
*/
|
|
3010
|
+
function stageMeta(line: string | undefined, description?: string): ReactNode {
|
|
3011
|
+
const words = description?.trim();
|
|
3012
|
+
if (!words) return line;
|
|
3013
|
+
return (
|
|
3014
|
+
<>
|
|
3015
|
+
{line ? <span className='cbmg-stage-facts'>{line}</span> : null}
|
|
3016
|
+
<span className='cbmg-stage-description'>{words}</span>
|
|
3017
|
+
</>
|
|
3018
|
+
);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2335
3021
|
/**
|
|
2336
3022
|
* The multi-select bar. Always mounted while `selectable` (never conditionally, so the grid
|
|
2337
3023
|
* does not jump the moment the first box is ticked) โ the destructive half only appears
|
|
@@ -2346,6 +3032,7 @@ function BulkBar({
|
|
|
2346
3032
|
onToggleAll,
|
|
2347
3033
|
onClear,
|
|
2348
3034
|
onDelete,
|
|
3035
|
+
onTag,
|
|
2349
3036
|
extra,
|
|
2350
3037
|
}: {
|
|
2351
3038
|
count: number;
|
|
@@ -2357,6 +3044,8 @@ function BulkBar({
|
|
|
2357
3044
|
onToggleAll: () => void;
|
|
2358
3045
|
onClear: () => void;
|
|
2359
3046
|
onDelete?: () => Promise<void>;
|
|
3047
|
+
/** Open the tag editor over the whole selection. Absent unless the app wired `onSaveMeta`. */
|
|
3048
|
+
onTag?: () => void;
|
|
2360
3049
|
/** App-specific actions, rendered before Delete. See `bulkActions` on the gallery. */
|
|
2361
3050
|
extra?: ReactNode;
|
|
2362
3051
|
}) {
|
|
@@ -2368,11 +3057,13 @@ function BulkBar({
|
|
|
2368
3057
|
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
2369
3058
|
// gallery's own plain CSS, matching its neighbors.
|
|
2370
3059
|
type='button'
|
|
2371
|
-
className={`cbgd-order-btn${armed ? ' is-active' : ''}`}
|
|
3060
|
+
className={`cbgd-order-btn is-icon${armed ? ' is-active' : ''}`}
|
|
2372
3061
|
aria-pressed={armed === true}
|
|
3062
|
+
// Same bargain as Pin: the two words were the readout, and they stay the name.
|
|
3063
|
+
aria-label={armed ? 'Done selecting' : 'Select'}
|
|
2373
3064
|
onClick={onToggleArmed}
|
|
2374
3065
|
>
|
|
2375
|
-
|
|
3066
|
+
<SquareCheck aria-hidden />
|
|
2376
3067
|
</button>
|
|
2377
3068
|
</Tooltip>
|
|
2378
3069
|
) : null}
|
|
@@ -2394,6 +3085,26 @@ function BulkBar({
|
|
|
2394
3085
|
{count > 0 ? (
|
|
2395
3086
|
<>
|
|
2396
3087
|
<span className='cbmg-bulkbar-spacer' />
|
|
3088
|
+
{/* The gallery's own bulk verb, ahead of the app's: this row reads the same in
|
|
3089
|
+
every app that wires it, for the same reason the detail strip puts the shared
|
|
3090
|
+
capabilities before `detailActions`. */}
|
|
3091
|
+
{onTag ? (
|
|
3092
|
+
<Tooltip content={`Tag the ${count} selected item${count === 1 ? '' : 's'} in one go`}>
|
|
3093
|
+
<button
|
|
3094
|
+
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
3095
|
+
// gallery's own plain CSS, matching its neighbors.
|
|
3096
|
+
type='button'
|
|
3097
|
+
className='cbgd-order-btn is-icon'
|
|
3098
|
+
// The count is in the NAME, because the visible label is a glyph and "tag"
|
|
3099
|
+
// without a number tells a screen reader nothing about what it acts on โ
|
|
3100
|
+
// the same argument the delete pill three lines down records.
|
|
3101
|
+
aria-label={`Tag the ${count} selected item${count === 1 ? '' : 's'}`}
|
|
3102
|
+
onClick={onTag}
|
|
3103
|
+
>
|
|
3104
|
+
<Tags aria-hidden />
|
|
3105
|
+
</button>
|
|
3106
|
+
</Tooltip>
|
|
3107
|
+
) : null}
|
|
2397
3108
|
{/* App actions come BEFORE Delete on purpose: Delete is the destructive one and
|
|
2398
3109
|
belongs at the end, furthest from the pointer's resting place after the
|
|
2399
3110
|
selection is made. */}
|
|
@@ -2411,19 +3122,26 @@ function BulkBar({
|
|
|
2411
3122
|
message: 'This cannot be undone.',
|
|
2412
3123
|
}}
|
|
2413
3124
|
onDelete={onDelete}
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
3125
|
+
/* ๐ด The COUNT drops off the pill and loses nothing, because this bar prints
|
|
3126
|
+
"{count} selected" three controls to the left โ it was the one number in
|
|
3127
|
+
the strip that was already stated twice. Both the name and the tooltip
|
|
3128
|
+
above still carry it, so a reader hovering the trash still learns how many
|
|
3129
|
+
it takes. */
|
|
3130
|
+
iconOnly
|
|
3131
|
+
/>
|
|
2417
3132
|
) : null}
|
|
2418
|
-
<
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
3133
|
+
<Tooltip content='Untick everything and leave the tiles alone'>
|
|
3134
|
+
<button
|
|
3135
|
+
// guardrails-ignore no-raw-action-button: action pill styled by the dense
|
|
3136
|
+
// gallery's own plain CSS, matching its neighbors.
|
|
3137
|
+
type='button'
|
|
3138
|
+
className='cbgd-order-btn is-icon'
|
|
3139
|
+
aria-label='Clear the selection'
|
|
3140
|
+
onClick={onClear}
|
|
3141
|
+
>
|
|
3142
|
+
<X aria-hidden />
|
|
3143
|
+
</button>
|
|
3144
|
+
</Tooltip>
|
|
2427
3145
|
</>
|
|
2428
3146
|
) : null}
|
|
2429
3147
|
</div>
|