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
|
@@ -143,7 +143,13 @@ describe('autoplay — a video opened as the SUBJECT plays on arrival, silently'
|
|
|
143
143
|
* doesn't loudly surprise people but it should play right away."
|
|
144
144
|
*/
|
|
145
145
|
test('the default is: play, and play silently', () => {
|
|
146
|
-
expect(resolveAutoplay({ reducedMotion: false })).toEqual({
|
|
146
|
+
expect(resolveAutoplay({ reducedMotion: false })).toEqual({
|
|
147
|
+
autoPlay: true,
|
|
148
|
+
muted: true,
|
|
149
|
+
// Full level UNDER the mute, so the control bar's unmute button behaves the way it did
|
|
150
|
+
// before session volume existed. See `SessionVolume`.
|
|
151
|
+
volume: 1,
|
|
152
|
+
});
|
|
147
153
|
});
|
|
148
154
|
|
|
149
155
|
test('muted follows autoPlay, because an UNMUTED autoplay is refused by every browser', () => {
|
|
@@ -157,6 +163,7 @@ describe('autoplay — a video opened as the SUBJECT plays on arrival, silently'
|
|
|
157
163
|
expect(resolveAutoplay({ autoPlay: false, reducedMotion: false })).toEqual({
|
|
158
164
|
autoPlay: false,
|
|
159
165
|
muted: false,
|
|
166
|
+
volume: 1,
|
|
160
167
|
});
|
|
161
168
|
});
|
|
162
169
|
|
|
@@ -177,6 +184,109 @@ describe('autoplay — a video opened as the SUBJECT plays on arrival, silently'
|
|
|
177
184
|
});
|
|
178
185
|
});
|
|
179
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 🔴 The volume he set stays set until he refreshes.
|
|
189
|
+
*
|
|
190
|
+
* Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
|
|
191
|
+
* changing it during a session except if I refresh or first log in then continue starting it
|
|
192
|
+
* at mute. Then if I unmute it, keep that value as I change to other videos."*
|
|
193
|
+
*
|
|
194
|
+
* The STORE half — `sessionStorage`, and never `localStorage` — is asserted in
|
|
195
|
+
* `media-gallery/playbackPreferences.spec.ts`. This is the half that decides what a mounting
|
|
196
|
+
* player actually opens at, and it is a pure function for the reason the block above says:
|
|
197
|
+
* `volume` and `muted` are two attributes on one inert custom element under happy-dom, and
|
|
198
|
+
* getting the precedence backwards renders identically.
|
|
199
|
+
*/
|
|
200
|
+
describe('a restored session volume', () => {
|
|
201
|
+
test('no session is byte-for-byte the old rule — a refresh therefore starts at mute', () => {
|
|
202
|
+
expect(resolveAutoplay({ autoPlay: true, reducedMotion: false, session: null })).toEqual({
|
|
203
|
+
autoPlay: true,
|
|
204
|
+
muted: true,
|
|
205
|
+
volume: 1,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('a level he set on one clip is what the NEXT clip opens at', () => {
|
|
210
|
+
expect(
|
|
211
|
+
resolveAutoplay({
|
|
212
|
+
autoPlay: true,
|
|
213
|
+
reducedMotion: false,
|
|
214
|
+
session: { volume: 0.4, muted: false },
|
|
215
|
+
}),
|
|
216
|
+
).toEqual({ autoPlay: true, muted: false, volume: 0.4 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('🔴 a muted session still carries his level, so unmuting is not a jump to full', () => {
|
|
220
|
+
const resolved = resolveAutoplay({
|
|
221
|
+
autoPlay: true,
|
|
222
|
+
reducedMotion: false,
|
|
223
|
+
session: { volume: 0.4, muted: true },
|
|
224
|
+
});
|
|
225
|
+
expect(resolved.muted).toBe(true);
|
|
226
|
+
// The level is applied to the ELEMENT under the mute — which is the entire second
|
|
227
|
+
// sentence of the ask. `volume: 0` here would satisfy "it is muted" and lose it.
|
|
228
|
+
expect(resolved.volume).toBe(0.4);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('an explicit `muted` prop still wins — an app that says silent means silent', () => {
|
|
232
|
+
expect(
|
|
233
|
+
resolveAutoplay({
|
|
234
|
+
muted: true,
|
|
235
|
+
reducedMotion: false,
|
|
236
|
+
session: { volume: 0.8, muted: false },
|
|
237
|
+
}).muted,
|
|
238
|
+
).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('a click-to-play player is not muted by a session that never mentioned it', () => {
|
|
242
|
+
// The `null`-vs-default distinction, seen from the consumer side: without it, every
|
|
243
|
+
// `autoPlay={false}` player in the fleet would come back silent on upgrade.
|
|
244
|
+
expect(resolveAutoplay({ autoPlay: false, reducedMotion: false, session: null }).muted).toBe(
|
|
245
|
+
false,
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('the player WIRES it — the props happy-dom cannot see for us', () => {
|
|
250
|
+
/*
|
|
251
|
+
* Vidstack's player is a custom element happy-dom never brings to life, so `volume`,
|
|
252
|
+
* `onVolumeChange` and `onAutoPlayFail` are unobservable in a rendered tree: dropping any
|
|
253
|
+
* of the three leaves every test above green and the feature gone. Asserted on the source
|
|
254
|
+
* the same way the hls.js engine is, two describes down.
|
|
255
|
+
*/
|
|
256
|
+
const source = readFileSync(join(import.meta.dir, 'VideoPlayer.tsx'), 'utf8');
|
|
257
|
+
expect(/volume=\{startVolume\}/.test(source)).toBe(true);
|
|
258
|
+
// Read off the PLAYER's own event, not a React slider — he changes this in Vidstack's
|
|
259
|
+
// control bar, which no React state can see.
|
|
260
|
+
expect(source).toContain('onVolumeChange={(detail)');
|
|
261
|
+
expect(source).toContain('writeSessionVolume(volumeKey, applied.current)');
|
|
262
|
+
// A refused unmuted autoplay must not leave a slideshow sitting on frame one.
|
|
263
|
+
expect(source).toContain('onAutoPlayFail={(detail)');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('🔴 a storage that THROWS does not take the render down', () => {
|
|
267
|
+
/*
|
|
268
|
+
* Safari's private mode and a sandboxed iframe throw on the ACCESS, not on the read, and
|
|
269
|
+
* an unguarded storage read on a player's mount path takes the whole gallery render down
|
|
270
|
+
* over a remembered volume. The session volume reaches for no web store at all — see
|
|
271
|
+
* `SessionVolume`, which is also why a refresh returns him to mute — so there is nothing
|
|
272
|
+
* here to be denied. Asserted rather than assumed: the day somebody moves it onto
|
|
273
|
+
* `localStorage`/`sessionStorage` without the `try` its neighbours carry, this reds.
|
|
274
|
+
*/
|
|
275
|
+
const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
|
276
|
+
Object.defineProperty(globalThis, 'sessionStorage', {
|
|
277
|
+
configurable: true,
|
|
278
|
+
get() {
|
|
279
|
+
throw new Error('SecurityError: access to sessionStorage is denied');
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
try {
|
|
283
|
+
expect(() => render(<VideoPlayer video={video} />)).not.toThrow();
|
|
284
|
+
} finally {
|
|
285
|
+
if (original) Object.defineProperty(globalThis, 'sessionStorage', original);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
180
290
|
/**
|
|
181
291
|
* 🔴 The timeline follows the drag, live.
|
|
182
292
|
*
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
MediaProvider,
|
|
10
10
|
type MediaProviderAdapter,
|
|
11
11
|
Poster,
|
|
12
|
+
Track,
|
|
13
|
+
useMediaState,
|
|
12
14
|
} from '@vidstack/react';
|
|
13
15
|
import {
|
|
14
16
|
DefaultVideoLayout,
|
|
@@ -17,7 +19,14 @@ import {
|
|
|
17
19
|
} from '@vidstack/react/player/layouts/default';
|
|
18
20
|
import { type MouseEvent as ReactMouseEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
|
19
21
|
import type { PlayableVideo } from 'cursedbelt-core/media';
|
|
22
|
+
import {
|
|
23
|
+
PLAYER_VOLUME_KEY,
|
|
24
|
+
readSessionVolume,
|
|
25
|
+
type SessionVolume,
|
|
26
|
+
writeSessionVolume,
|
|
27
|
+
} from '../media-gallery/playbackPreferences';
|
|
20
28
|
import { isSurfaceControl } from './surfaceGesture';
|
|
29
|
+
import { chapterSpans, type ChapteredVideo, type VideoChapter } from './videoChapters';
|
|
21
30
|
|
|
22
31
|
/**
|
|
23
32
|
* The reusable video player primitive. Consumes a {@link PlayableVideo} (URLs already
|
|
@@ -33,16 +42,36 @@ import { isSurfaceControl } from './surfaceGesture';
|
|
|
33
42
|
* `<video>` element safe here — no canvas workaround. Scrub-preview thumbnails come from the
|
|
34
43
|
* source's WebVTT track (`vttUrl`); while derivatives are still being built, a thin progress
|
|
35
44
|
* bar rides the bottom edge of the surface (the original bytes stay playable behind it).
|
|
45
|
+
*
|
|
46
|
+
* Named sections (`chapters`) become a real `kind='chapters'` text track, which is what turns
|
|
47
|
+
* Vidstack's default layout from a bare bar into a YouTube-shaped one — dividers on the time
|
|
48
|
+
* slider, the current section's name above it, and a chapters menu. See {@link ChaptersTrack}.
|
|
36
49
|
*/
|
|
37
50
|
export interface VideoPlayerProps {
|
|
38
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The clip, with every URL already resolved — and its named sections, when the app carries
|
|
53
|
+
* any. {@link ChapteredVideo} is `PlayableVideo` plus `chapters`, so every existing source
|
|
54
|
+
* still satisfies this unchanged.
|
|
55
|
+
*/
|
|
56
|
+
video: ChapteredVideo;
|
|
39
57
|
/**
|
|
40
58
|
* Start playing on mount. **Defaults to `true`** — see {@link usePrefersReducedMotion} and
|
|
41
59
|
* the block on {@link AUTOPLAY_DEFAULT}.
|
|
42
60
|
*/
|
|
43
61
|
autoPlay?: boolean;
|
|
44
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Start silent. **Defaults to `true` whenever autoplay is in effect** (see below) — unless
|
|
64
|
+
* the person has already set a volume on this surface THIS SESSION, which wins over both.
|
|
65
|
+
*/
|
|
45
66
|
muted?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Where this surface's session volume is kept. Defaults to {@link PLAYER_VOLUME_KEY}.
|
|
69
|
+
*
|
|
70
|
+
* 🔴 Pass a different key for a player that is a DIFFERENT surface — the gallery stage
|
|
71
|
+
* passes `STAGE_VOLUME_KEY` — for the reason `media/stageResize.ts` keys the remembered
|
|
72
|
+
* height: one shared key makes two viewers on one page fight over a single number.
|
|
73
|
+
*/
|
|
74
|
+
volumeKey?: string;
|
|
46
75
|
className?: string;
|
|
47
76
|
/**
|
|
48
77
|
* Intrinsic aspect ratio (a CSS `aspect-ratio` value, e.g. `'16/9'` or `'4/3'`). Gives the
|
|
@@ -326,21 +355,45 @@ export const AUTOPLAY_DEFAULT = true;
|
|
|
326
355
|
* preference", which is the behavior everything had before this existed.
|
|
327
356
|
*/
|
|
328
357
|
/**
|
|
329
|
-
* The autoplay decision
|
|
358
|
+
* The autoplay decision — and, since 2026-09-16, how loud the clip opens.
|
|
330
359
|
*
|
|
331
360
|
* 🔴 It is extracted rather than inlined because nothing below a real browser can see the
|
|
332
361
|
* difference: `autoPlay` and `muted` are two props on one JSX element, and swapping them,
|
|
333
362
|
* dropping the reduced-motion term, or letting `muted` default independently all typecheck
|
|
334
363
|
* and render identically under happy-dom. The same reasoning `posterFraming.ts` and
|
|
335
364
|
* `mediaGalleryModel.ts` already apply to their own decisions.
|
|
365
|
+
*
|
|
366
|
+
* ── 🔴 Where `session` sits in the precedence, and why ──────────────────────────────────
|
|
367
|
+
* Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
|
|
368
|
+
* changing it during a session except if I refresh or first log in then continue starting it
|
|
369
|
+
* at mute. Then if I unmute it, keep that value as I change to other videos."*
|
|
370
|
+
*
|
|
371
|
+
* · an explicit `muted` prop still wins — an app that says "silent" means silent
|
|
372
|
+
* · then what he set THIS SESSION ({@link readSessionVolume}, `null` until he touches it)
|
|
373
|
+
* · then the old rule: muted follows autoPlay, because an unmuted autoplay is refused
|
|
374
|
+
*
|
|
375
|
+
* A `null` session is therefore byte-for-byte the behavior every consumer had before this
|
|
376
|
+
* existed, which is what makes "a refresh starts at mute" true rather than merely intended:
|
|
377
|
+
* a fresh tab has no session entry, so a fresh tab takes the third line.
|
|
378
|
+
*
|
|
379
|
+
* `volume` is returned separately from `muted` on purpose — see {@link SessionVolume}. A mute
|
|
380
|
+
* stored as `volume: 0` would make the control bar's unmute button return to silence, and
|
|
381
|
+
* *"if I unmute it, keep that value"* is the sentence that forbids it.
|
|
336
382
|
*/
|
|
337
383
|
export function resolveAutoplay(opts: {
|
|
338
384
|
autoPlay?: boolean;
|
|
339
385
|
muted?: boolean;
|
|
340
386
|
reducedMotion: boolean;
|
|
341
|
-
|
|
387
|
+
/** What he set this session on this surface, or `null`/absent for "he has not". */
|
|
388
|
+
session?: SessionVolume | null;
|
|
389
|
+
}): { autoPlay: boolean; muted: boolean; volume: number } {
|
|
342
390
|
const autoPlay = (opts.autoPlay ?? AUTOPLAY_DEFAULT) && !opts.reducedMotion;
|
|
343
|
-
|
|
391
|
+
const session = opts.session ?? null;
|
|
392
|
+
return {
|
|
393
|
+
autoPlay,
|
|
394
|
+
muted: opts.muted ?? session?.muted ?? autoPlay,
|
|
395
|
+
volume: session?.volume ?? 1,
|
|
396
|
+
};
|
|
344
397
|
}
|
|
345
398
|
|
|
346
399
|
/**
|
|
@@ -459,6 +512,7 @@ export function VideoPlayer({
|
|
|
459
512
|
video,
|
|
460
513
|
autoPlay,
|
|
461
514
|
muted,
|
|
515
|
+
volumeKey = PLAYER_VOLUME_KEY,
|
|
462
516
|
className,
|
|
463
517
|
aspectRatio = DEFAULT_VIDEO_ASPECT_RATIO,
|
|
464
518
|
onEnded,
|
|
@@ -475,18 +529,37 @@ export function VideoPlayer({
|
|
|
475
529
|
: video.streamUrl;
|
|
476
530
|
|
|
477
531
|
// See AUTOPLAY_DEFAULT. `muted` follows `autoPlay` because an unmuted autoplay is refused
|
|
478
|
-
// by every browser and the refusal LOOKS like the spinner this batch removed
|
|
532
|
+
// by every browser and the refusal LOOKS like the spinner this batch removed — unless he
|
|
533
|
+
// has already set a level on this surface this session, which is what `session` carries.
|
|
479
534
|
const reducedMotion = usePrefersReducedMotion();
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
535
|
+
// 🔴 Read ONCE, on mount, and never again: this is a starting value handed to a player that
|
|
536
|
+
// owns its own volume state from then on. Re-reading it on a later render would fight the
|
|
537
|
+
// control bar he is holding, and stepping to the next clip remounts this component anyway,
|
|
538
|
+
// which is exactly when the remembered level is supposed to be applied.
|
|
539
|
+
const [session] = useState<SessionVolume | null>(() => readSessionVolume(volumeKey));
|
|
540
|
+
const {
|
|
541
|
+
autoPlay: shouldAutoPlay,
|
|
542
|
+
muted: shouldMute,
|
|
543
|
+
volume: startVolume,
|
|
544
|
+
} = resolveAutoplay({ autoPlay, muted, reducedMotion, session });
|
|
485
545
|
|
|
486
546
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
487
547
|
const playerRef = useRef<MediaPlayerInstance>(null);
|
|
488
548
|
useLiveScrub(hostRef, playerRef, liveScrub);
|
|
489
549
|
|
|
550
|
+
/*
|
|
551
|
+
* 🔴 What is on the element right now, so the RESTORE is not mistaken for a change.
|
|
552
|
+
*
|
|
553
|
+
* Applying `volume`/`muted` above makes the element fire `volumechange` like any other
|
|
554
|
+
* change, so a naive listener writes the starting value straight back — minting a remembered
|
|
555
|
+
* preference for a person who has touched nothing, on every player that mounts. That
|
|
556
|
+
* quietly turns "he has not set a volume" into "he set it to the default", which is the one
|
|
557
|
+
* distinction `readSessionVolume` returns `null` to preserve.
|
|
558
|
+
*/
|
|
559
|
+
const applied = useRef({ volume: startVolume, muted: shouldMute });
|
|
560
|
+
/** A mute the BROWSER forced on us — see `onAutoPlayFail` below. Never his choice, so never stored. */
|
|
561
|
+
const forcedMute = useRef(false);
|
|
562
|
+
|
|
490
563
|
const status = video.processingStatus;
|
|
491
564
|
const showProcessing = status != null && status !== 'ready';
|
|
492
565
|
|
|
@@ -527,6 +600,40 @@ export function VideoPlayer({
|
|
|
527
600
|
playsInline
|
|
528
601
|
autoPlay={shouldAutoPlay}
|
|
529
602
|
muted={shouldMute}
|
|
603
|
+
// 🔴 The level he set this session, restored on the element — see `resolveAutoplay`
|
|
604
|
+
// and `SessionVolume`. Both values are constant for the life of this mount, so
|
|
605
|
+
// Vidstack's own volume state is never overwritten while he is using the control bar;
|
|
606
|
+
// the next clip is a fresh mount, which is when the restore is meant to happen.
|
|
607
|
+
volume={startVolume}
|
|
608
|
+
/* 🔴 Read off the PLAYER, not off a React slider: he changes this in Vidstack's own
|
|
609
|
+
control bar (or with ↑/↓), which no React-side state can see. `volume-change` fires
|
|
610
|
+
for both facts at once, which is the shape `SessionVolume` stores. */
|
|
611
|
+
onVolumeChange={(detail) => {
|
|
612
|
+
// The echo of our own restore is not a change he made. See `applied`.
|
|
613
|
+
if (detail.volume === applied.current.volume && detail.muted === applied.current.muted)
|
|
614
|
+
return;
|
|
615
|
+
applied.current = { volume: detail.volume, muted: detail.muted };
|
|
616
|
+
if (forcedMute.current) {
|
|
617
|
+
// The browser's refusal, not his preference — leave the stored level alone so
|
|
618
|
+
// unmuting still returns to it.
|
|
619
|
+
forcedMute.current = false;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
writeSessionVolume(volumeKey, applied.current);
|
|
623
|
+
}}
|
|
624
|
+
/* 🔴 A restored volume must not be able to break the slideshow.
|
|
625
|
+
`playbackRun.ts` drives whole folders by mounting the next clip with `autoPlay`,
|
|
626
|
+
and a browser refuses an unmuted autoplay without a prior gesture — so a session
|
|
627
|
+
that remembers "unmuted" could, on the first clip after a restore, leave the run
|
|
628
|
+
sitting on frame one. Falling back to a muted play keeps the run running; the level
|
|
629
|
+
stays on the element, so one press of unmute is still HIS level rather than full. */
|
|
630
|
+
onAutoPlayFail={(detail) => {
|
|
631
|
+
const player = playerRef.current;
|
|
632
|
+
if (!player || detail.muted) return;
|
|
633
|
+
forcedMute.current = true;
|
|
634
|
+
player.muted = true;
|
|
635
|
+
void player.play().catch(() => {});
|
|
636
|
+
}}
|
|
530
637
|
// Load eagerly rather than on-visible: the watch surface can size to a
|
|
531
638
|
// zero-height box during iOS Safari's container-query resolution, so a
|
|
532
639
|
// `visible` gate never fired and the player sat on a perpetual spinner.
|
|
@@ -552,6 +659,7 @@ export function VideoPlayer({
|
|
|
552
659
|
single-click gesture stays a gesture: `toggle:paused` over the centre band is
|
|
553
660
|
exactly what it is for, and Vidstack already declines it over the controls. */}
|
|
554
661
|
<Gesture className='vds-gesture' event='pointerup' action='toggle:paused' />
|
|
662
|
+
<ChaptersTrack chapters={video.chapters} />
|
|
555
663
|
</MediaProvider>
|
|
556
664
|
<DefaultVideoLayout
|
|
557
665
|
icons={defaultLayoutIcons}
|
|
@@ -578,6 +686,45 @@ export function VideoPlayer({
|
|
|
578
686
|
);
|
|
579
687
|
}
|
|
580
688
|
|
|
689
|
+
/**
|
|
690
|
+
* The named sections, as the `kind='chapters'` text track Vidstack's default layout is already
|
|
691
|
+
* looking for.
|
|
692
|
+
*
|
|
693
|
+
* ── 🔴 Why this is a component and not three lines inside the player ──────────────────
|
|
694
|
+
* Because a chapter cue needs an END, and the only place the clip's real length is known is
|
|
695
|
+
* INSIDE the player. `PlayableVideo.durationSeconds` is optional and frequently absent — and
|
|
696
|
+
* where it is present it is whatever the app's catalogue recorded, which is routinely rounded
|
|
697
|
+
* to the second. Building the cues from that would put every divider a fraction out on the one
|
|
698
|
+
* surface where they are compared against the picture, and would draw nothing at all for the
|
|
699
|
+
* ordinary video whose row has no duration on it. `useMediaState('duration')` is the decoder's
|
|
700
|
+
* own number, and it only reads it from a component mounted within the player's context —
|
|
701
|
+
* which is what this one is.
|
|
702
|
+
*
|
|
703
|
+
* Handed `content` rather than a `src`, so no URL has to be minted, served, signed or revoked
|
|
704
|
+
* for a list of strings the browser already has. Vidstack's `type='json'` parser takes the cue
|
|
705
|
+
* objects directly.
|
|
706
|
+
*
|
|
707
|
+
* Renders nothing until there is a real duration and at least one section: an empty chapters
|
|
708
|
+
* track is not neutral, it makes the layout draw a chapter title area and a menu entry for a
|
|
709
|
+
* video that has no chapters.
|
|
710
|
+
*/
|
|
711
|
+
function ChaptersTrack({ chapters }: { chapters?: readonly VideoChapter[] }) {
|
|
712
|
+
const duration = useMediaState('duration');
|
|
713
|
+
const content = useMemo(() => {
|
|
714
|
+
const spans = chapterSpans(chapters ?? [], duration);
|
|
715
|
+
if (spans.length === 0) return null;
|
|
716
|
+
return {
|
|
717
|
+
cues: spans.map((span) => ({
|
|
718
|
+
startTime: span.startSeconds,
|
|
719
|
+
endTime: span.endSeconds,
|
|
720
|
+
text: span.title,
|
|
721
|
+
})),
|
|
722
|
+
};
|
|
723
|
+
}, [chapters, duration]);
|
|
724
|
+
if (!content) return null;
|
|
725
|
+
return <Track content={content} type='json' kind='chapters' label='Chapters' default />;
|
|
726
|
+
}
|
|
727
|
+
|
|
581
728
|
/**
|
|
582
729
|
* The processing state for a video surface: a thin progress bar pinned across the bottom edge
|
|
583
730
|
* while derivatives build (indeterminate shimmer until real progress arrives), or a compact
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* with itself.
|
|
10
10
|
*/
|
|
11
11
|
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
import type React from 'react';
|
|
12
13
|
import { fireEvent, render } from '@testing-library/react';
|
|
13
14
|
import { type MediaOrientation, VideoTurner } from './VideoTurner';
|
|
14
15
|
|
|
@@ -60,6 +61,72 @@ describe('VideoTurner', () => {
|
|
|
60
61
|
);
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 🔴 The dialog SAYS when it cannot show the footage — the owner's `ideas/collections-09-14.md`
|
|
66
|
+
* report: *"When I click the turn the video button I see a black screen."*
|
|
67
|
+
*
|
|
68
|
+
* It was mounted with an empty `src` (see `MediaGallery`'s `TurnVideoDialog` for the cause),
|
|
69
|
+
* and a `<video>` with nothing to load never fires `error` — it paints black and stays that
|
|
70
|
+
* way. So a preview that cannot happen must be a sentence, not a dark rectangle: that is the
|
|
71
|
+
* difference between a report anyone can act on and *"I see a black screen"*.
|
|
72
|
+
*/
|
|
73
|
+
describe('when it cannot show the footage', () => {
|
|
74
|
+
const openWith = (props: Partial<React.ComponentProps<typeof VideoTurner>>) =>
|
|
75
|
+
render(<VideoTurner src='' onClose={() => {}} onSave={() => {}} {...props} />);
|
|
76
|
+
|
|
77
|
+
test('🔴 no source at all is a stated reason, not a black box', () => {
|
|
78
|
+
// `baseElement`, not `container`: the dialog is a portal, so its frame is not under the
|
|
79
|
+
// render root. The same reason every assertion here goes through a Testing Library query.
|
|
80
|
+
const { baseElement, getByRole } = openWith({});
|
|
81
|
+
expect(baseElement.querySelector('video')).toBeNull();
|
|
82
|
+
const said = getByRole('status').textContent ?? '';
|
|
83
|
+
expect(said).toContain('no video source');
|
|
84
|
+
// …and it says what is still true: the turn is metadata, so it can be set without a
|
|
85
|
+
// preview. Withholding that would turn a preview failure into a lost capability.
|
|
86
|
+
expect(said).toContain('save');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('🔴 a source that fails to load says so too', () => {
|
|
90
|
+
const { baseElement, getByRole, queryByRole } = openWith({ src: '/files/gone' });
|
|
91
|
+
const video = baseElement.querySelector('video');
|
|
92
|
+
expect(video).not.toBeNull();
|
|
93
|
+
expect(queryByRole('status')).toBeNull();
|
|
94
|
+
fireEvent.error(video as HTMLVideoElement);
|
|
95
|
+
expect(getByRole('status').textContent).toContain('could not be loaded');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('a source still being resolved reads as "on its way", not as a failure', () => {
|
|
99
|
+
// A signed URL takes a round trip to mint. Flashing "there is nothing to preview" at
|
|
100
|
+
// somebody whose film is on its way would be a false report the dialog invented itself.
|
|
101
|
+
const { getByRole } = openWith({ src: '', resolving: true });
|
|
102
|
+
expect(getByRole('status').textContent).toContain('Finding this film');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the poster stands in when the film will not play, so a turn still has a picture', () => {
|
|
106
|
+
const { baseElement } = openWith({ src: '', posterUrl: '/thumbs/clip' });
|
|
107
|
+
const poster = baseElement.querySelector<HTMLImageElement>('.cbpe-frame-film img');
|
|
108
|
+
expect(poster?.getAttribute('src')).toBe('/thumbs/clip');
|
|
109
|
+
// It is the picture being turned, so it carries the same transform the film would have.
|
|
110
|
+
expect(poster?.style.transform).toBe('none');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('🔴 the turn can still be set and SAVED with no preview at all', () => {
|
|
114
|
+
const saved: (MediaOrientation | undefined)[] = [];
|
|
115
|
+
const { getByText } = render(
|
|
116
|
+
<VideoTurner
|
|
117
|
+
src=''
|
|
118
|
+
onClose={() => {}}
|
|
119
|
+
onSave={(next) => {
|
|
120
|
+
saved.push(next);
|
|
121
|
+
}}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
fireEvent.click(getByText('↻ Right'));
|
|
125
|
+
fireEvent.click(getByText('Save the turn'));
|
|
126
|
+
expect(saved).toEqual([{ turns: 1 }]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
63
130
|
test('a video is never re-encoded — there is no crop or light control to press', () => {
|
|
64
131
|
// Both are real for a picture (the canvas bakes them) and impossible for a clip in the
|
|
65
132
|
// browser. Offering them here would either lie or silently drop what somebody asked for.
|
|
@@ -14,10 +14,23 @@ export interface MediaOrientation {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface VideoTurnerProps {
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The clip to turn. Plays muted in the dialog so the preview is the real footage.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 The RESOLVED source, not whatever URL field an item happens to carry — see
|
|
21
|
+
* `MediaGallery`'s `TurnVideoDialog`, where handing over an empty string was the whole of
|
|
22
|
+
* the owner's *"I see a black screen"*. An empty string here is now a stated reason rather
|
|
23
|
+
* than a black rectangle, but it is still nothing to preview.
|
|
24
|
+
*/
|
|
18
25
|
src: string;
|
|
19
26
|
/** Shown until the first frame decodes — a black box reads as a broken player. */
|
|
20
27
|
posterUrl?: string;
|
|
28
|
+
/**
|
|
29
|
+
* True while the app is still finding the source (a signed URL being minted, bytes being
|
|
30
|
+
* fetched). It is the difference between "on its way" and "cannot be shown", and only the
|
|
31
|
+
* caller knows which.
|
|
32
|
+
*/
|
|
33
|
+
resolving?: boolean;
|
|
21
34
|
name?: string;
|
|
22
35
|
/** What is stored today, so re-opening the dialog starts from the current state. */
|
|
23
36
|
orientation?: MediaOrientation | undefined;
|
|
@@ -49,6 +62,7 @@ export interface VideoTurnerProps {
|
|
|
49
62
|
export function VideoTurner({
|
|
50
63
|
src,
|
|
51
64
|
posterUrl,
|
|
65
|
+
resolving = false,
|
|
52
66
|
name,
|
|
53
67
|
orientation,
|
|
54
68
|
open = true,
|
|
@@ -67,6 +81,30 @@ export function VideoTurner({
|
|
|
67
81
|
const [edit, setEdit] = React.useState<PictureEdit>(initial);
|
|
68
82
|
const [saving, setSaving] = React.useState(false);
|
|
69
83
|
const [error, setError] = React.useState<string | null>(null);
|
|
84
|
+
// A source that changes is a fresh attempt: it must not inherit the previous one's failure,
|
|
85
|
+
// the same rule the gallery's own `ThumbImg` keeps.
|
|
86
|
+
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Why there is no moving picture — `null` when there is one.
|
|
90
|
+
*
|
|
91
|
+
* 🔴 The dialog SAYS this instead of rendering black, which is the whole of the owner's
|
|
92
|
+
* report: *"When I click the turn the video button I see a black screen."* He could not tell
|
|
93
|
+
* a broken dialog from a dark frame, and neither could anyone reading a bug report about it.
|
|
94
|
+
* Each reason also says what is still true — the turn is three integers on a row, so it can
|
|
95
|
+
* be set and saved with no preview at all, and refusing to let him would turn a preview
|
|
96
|
+
* failure into a lost capability.
|
|
97
|
+
*/
|
|
98
|
+
const cannotShow: string | null = resolving
|
|
99
|
+
? 'Finding this film…'
|
|
100
|
+
: !src
|
|
101
|
+
? 'This file has no video source to play here, so there is nothing to preview. The turn is stored beside the film rather than encoded into it, so you can still set it and save.'
|
|
102
|
+
: failedSrc === src
|
|
103
|
+
? 'The film could not be loaded, so there is nothing to preview. The turn is stored beside the film rather than encoded into it, so you can still set it and save.'
|
|
104
|
+
: null;
|
|
105
|
+
// A poster is the next best picture of the film, and it turns with the same transform — so a
|
|
106
|
+
// clip that will not play is still something to aim a turn at rather than a blank panel.
|
|
107
|
+
const posterStandsIn = cannotShow !== null && !resolving && Boolean(posterUrl);
|
|
70
108
|
|
|
71
109
|
const dirty =
|
|
72
110
|
edit.turns !== initial.turns || edit.flipX !== initial.flipX || edit.flipY !== initial.flipY;
|
|
@@ -124,21 +162,38 @@ export function VideoTurner({
|
|
|
124
162
|
}
|
|
125
163
|
>
|
|
126
164
|
<div className='cbpe'>
|
|
127
|
-
{/* `data-turned` swaps the
|
|
128
|
-
|
|
129
|
-
it
|
|
165
|
+
{/* `data-turned` swaps the box's axes for the odd quarter turns: the clip is still LAID
|
|
166
|
+
OUT the way it was filmed, so without it a portrait clip turned upright is previewed
|
|
167
|
+
at whatever size it already had — a pillar, where the stage is about to show it
|
|
168
|
+
full-width. Same correction, and the same measurement, as the gallery stage makes. */}
|
|
130
169
|
<div className='cbpe-frame cbpe-frame-film' data-turned={swapsAxes(edit.turns) ? 'true' : undefined}>
|
|
131
|
-
{
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
170
|
+
{cannotShow === null ? (
|
|
171
|
+
/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored footage carries none */
|
|
172
|
+
<video
|
|
173
|
+
// A new source is a new attempt — see `failedSrc`.
|
|
174
|
+
key={src}
|
|
175
|
+
src={src}
|
|
176
|
+
poster={posterUrl}
|
|
177
|
+
muted
|
|
178
|
+
loop
|
|
179
|
+
autoPlay
|
|
180
|
+
playsInline
|
|
181
|
+
controls={false}
|
|
182
|
+
// 🔴 `metadata` at least. `preload='none'` would leave the element with nothing
|
|
183
|
+
// decoded and nothing drawn until something pressed play, which is a black frame
|
|
184
|
+
// wearing the same face as a broken source.
|
|
185
|
+
preload='metadata'
|
|
186
|
+
onError={() => setFailedSrc(src)}
|
|
187
|
+
style={{ transform: previewTransform(edit) }}
|
|
188
|
+
/>
|
|
189
|
+
) : posterStandsIn ? (
|
|
190
|
+
<img src={posterUrl} alt={name ?? 'The film'} style={{ transform: previewTransform(edit) }} />
|
|
191
|
+
) : null}
|
|
192
|
+
{cannotShow ? (
|
|
193
|
+
<p className='cbpe-frame-note' role='status'>
|
|
194
|
+
{cannotShow}
|
|
195
|
+
</p>
|
|
196
|
+
) : null}
|
|
142
197
|
</div>
|
|
143
198
|
|
|
144
199
|
<div className='cbpe-row'>
|