cursedbelt 4.1.1 → 4.3.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/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
|
@@ -46,10 +46,25 @@ import {
|
|
|
46
46
|
* by every visit after the first.
|
|
47
47
|
*/
|
|
48
48
|
export interface VideoChapterEditorProps {
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* The clip, as a URL a `<video>` can play.
|
|
51
|
+
*
|
|
52
|
+
* 🔴 The RESOLVED source, not whatever URL field a record happens to carry — see
|
|
53
|
+
* `MediaGallery`'s `ChapterVideoDialog`. A gallery item is allowed to carry no URL at all
|
|
54
|
+
* (`resolveMedia` is the documented path for a signed URL), and a `<video>` with an empty
|
|
55
|
+
* source never loads, never fires `error` and paints black forever: the owner's *"I see a
|
|
56
|
+
* black screen"* report, in a different dialog. An empty string here is now a stated reason,
|
|
57
|
+
* and the sections themselves stay editable — they are metadata beside the film.
|
|
58
|
+
*/
|
|
50
59
|
src: string;
|
|
51
60
|
/** Shown until the first frame decodes — a black box reads as a broken player. */
|
|
52
61
|
posterUrl?: string;
|
|
62
|
+
/**
|
|
63
|
+
* True while the app is still finding the source (a signed URL being minted, bytes being
|
|
64
|
+
* fetched). It is the difference between "on its way" and "cannot be shown", and only the
|
|
65
|
+
* caller knows which.
|
|
66
|
+
*/
|
|
67
|
+
resolving?: boolean;
|
|
53
68
|
name?: string;
|
|
54
69
|
/**
|
|
55
70
|
* The clip's length, when the app already knows it. The element's own metadata still wins
|
|
@@ -80,6 +95,7 @@ export interface VideoChapterEditorProps {
|
|
|
80
95
|
export function VideoChapterEditor({
|
|
81
96
|
src,
|
|
82
97
|
posterUrl,
|
|
98
|
+
resolving = false,
|
|
83
99
|
name,
|
|
84
100
|
durationSeconds,
|
|
85
101
|
chapters: initialChapters,
|
|
@@ -108,6 +124,25 @@ export function VideoChapterEditor({
|
|
|
108
124
|
const [draftTitle, setDraftTitle] = React.useState('');
|
|
109
125
|
const [submitting, setSubmitting] = React.useState(false);
|
|
110
126
|
const [error, setError] = React.useState<string | null>(null);
|
|
127
|
+
// A source that changes is a fresh attempt: it must not inherit the previous one's failure,
|
|
128
|
+
// the same rule `VideoTurner` and the gallery's own `ThumbImg` keep.
|
|
129
|
+
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Why there is no film to scrub — `null` when there is one.
|
|
133
|
+
*
|
|
134
|
+
* 🔴 Said out loud rather than drawn as the black rectangle a source-less `<video>` paints,
|
|
135
|
+
* which is the owner's *"I see a black screen"* in the sibling dialog. Each reason also says
|
|
136
|
+
* what is still true: a section is a name and a timestamp stored BESIDE the film, so the
|
|
137
|
+
* ones this video already has can still be renamed, removed and saved with no player at all.
|
|
138
|
+
*/
|
|
139
|
+
const cannotShow: string | null = resolving
|
|
140
|
+
? 'Finding this film…'
|
|
141
|
+
: !src
|
|
142
|
+
? 'This file has no video source to play here, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
|
|
143
|
+
: failedSrc === src
|
|
144
|
+
? 'The film could not be loaded, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
|
|
145
|
+
: null;
|
|
111
146
|
|
|
112
147
|
const spans = React.useMemo(() => chapterSpans(chapters, duration), [chapters, duration]);
|
|
113
148
|
/** What Save would send. Also what `isDirty` compares, so the two can never disagree. */
|
|
@@ -265,23 +300,34 @@ export function VideoChapterEditor({
|
|
|
265
300
|
onKeyDown={onKeyDown}
|
|
266
301
|
aria-label='Name the sections of this video. Type a name, then mark the point it starts at. Space plays and pauses.'
|
|
267
302
|
>
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
303
|
+
{cannotShow === null ? (
|
|
304
|
+
// biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions.
|
|
305
|
+
<video
|
|
306
|
+
ref={videoRef}
|
|
307
|
+
className='cbch-video'
|
|
308
|
+
// A new source is a new attempt — see `failedSrc`.
|
|
309
|
+
key={src}
|
|
310
|
+
src={src}
|
|
311
|
+
poster={posterUrl}
|
|
312
|
+
controls
|
|
313
|
+
playsInline
|
|
314
|
+
preload='metadata'
|
|
315
|
+
onLoadedMetadata={(event) => {
|
|
316
|
+
const seen = event.currentTarget.duration;
|
|
317
|
+
// The element's own metadata wins over anything the catalog rounded.
|
|
318
|
+
if (Number.isFinite(seen) && seen > 0) setDuration(seen);
|
|
319
|
+
}}
|
|
320
|
+
onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
321
|
+
onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
322
|
+
onError={() => setFailedSrc(src)}
|
|
323
|
+
/>
|
|
324
|
+
) : (
|
|
325
|
+
/* In the film's own place and at its own size — a sentence under a black rectangle
|
|
326
|
+
would leave the rectangle there to be reported as a black screen. */
|
|
327
|
+
<p className='cbch-video cbch-nofilm' role='status'>
|
|
328
|
+
{cannotShow}
|
|
329
|
+
</p>
|
|
330
|
+
)}
|
|
285
331
|
|
|
286
332
|
<div className='cbch-add'>
|
|
287
333
|
<div className='cbch-add-field'>
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { afterEach, describe, expect, test } from 'bun:test';
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import type React from 'react';
|
|
13
14
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
14
15
|
import { VideoCutEditor } from './VideoCutEditor';
|
|
15
16
|
|
|
@@ -124,3 +125,46 @@ describe('what gets sent', () => {
|
|
|
124
125
|
expect(sent).toHaveLength(0);
|
|
125
126
|
});
|
|
126
127
|
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 🔴 The dialog SAYS when it has no film — it never renders the black rectangle.
|
|
131
|
+
*
|
|
132
|
+
* Filed from the turn dialog's report (`ideas/collections-09-14.md`: *"When I click the turn the
|
|
133
|
+
* video button I see a black screen"*) after the same cause was found in the four sibling
|
|
134
|
+
* dialogs: this one was mounted `src={item.streamUrl ?? item.url ?? ''}`, off the raw row, and a
|
|
135
|
+
* gallery item is allowed to carry neither field (`resolveMedia` is the documented path for a
|
|
136
|
+
* signed URL). A `<video>` with an empty source never loads, never fires `error` and paints
|
|
137
|
+
* black forever — so the only way anyone could report it was as a black screen. The gallery now
|
|
138
|
+
* hands over the RESOLVED source (`MediaGallery.spec.tsx`, "the TRIM dialog is handed the
|
|
139
|
+
* RESOLVED source"); this is the other half, for when there genuinely is no film.
|
|
140
|
+
*/
|
|
141
|
+
describe('when it cannot show the film', () => {
|
|
142
|
+
const openWith = (props: Partial<React.ComponentProps<typeof VideoCutEditor>>) =>
|
|
143
|
+
render(<VideoCutEditor src='' onClose={() => undefined} onSubmit={() => undefined} {...props} />);
|
|
144
|
+
|
|
145
|
+
test('🔴 no source at all is a stated reason, not a black box', () => {
|
|
146
|
+
// A portal: the dialog's body is on `document.body`, not under the render root.
|
|
147
|
+
const { getByRole } = openWith({});
|
|
148
|
+
expect(document.body.querySelector('video')).toBeNull();
|
|
149
|
+
const said = getByRole('status').textContent ?? '';
|
|
150
|
+
expect(said).toContain('no video source');
|
|
151
|
+
// …and it says what is still true — nothing was sent, so nothing was re-encoded.
|
|
152
|
+
expect(said).toContain('Nothing has been sent');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('🔴 a source that fails to load says so too', () => {
|
|
156
|
+
const { getByRole, queryByRole } = openWith({ src: '/films/gone.mp4' });
|
|
157
|
+
const film = document.body.querySelector('video');
|
|
158
|
+
expect(film).not.toBeNull();
|
|
159
|
+
expect(queryByRole('status')).toBeNull();
|
|
160
|
+
fireEvent.error(film as HTMLVideoElement);
|
|
161
|
+
expect(getByRole('status').textContent).toContain('could not be loaded');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('a source still being resolved reads as "on its way", not as a failure', () => {
|
|
165
|
+
// A signed URL takes a round trip to mint. Flashing "there is nothing to mark up" at
|
|
166
|
+
// somebody whose film is on its way would be a false report the dialog invented itself.
|
|
167
|
+
const { getByRole } = openWith({ src: '', resolving: true });
|
|
168
|
+
expect(getByRole('status').textContent).toContain('Finding this film');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -74,10 +74,24 @@ import {
|
|
|
74
74
|
* exists, in one predictable place, and pays no permanent rent in the dialog.
|
|
75
75
|
*/
|
|
76
76
|
export interface VideoCutEditorProps {
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The clip, as a URL a `<video>` can play.
|
|
79
|
+
*
|
|
80
|
+
* 🔴 The RESOLVED source, not whatever URL field a record happens to carry — see
|
|
81
|
+
* `MediaGallery`'s `TrimVideoDialog`. A gallery item is allowed to carry no URL at all
|
|
82
|
+
* (`resolveMedia` is the documented path for a signed URL), and a `<video>` with an empty
|
|
83
|
+
* source never loads, never fires `error` and paints black forever: the owner's *"I see a
|
|
84
|
+
* black screen"* report, in a different dialog. An empty string here is now a stated reason.
|
|
85
|
+
*/
|
|
78
86
|
src: string;
|
|
79
87
|
/** Shown until the first frame decodes — a black box reads as a broken player. */
|
|
80
88
|
posterUrl?: string;
|
|
89
|
+
/**
|
|
90
|
+
* True while the app is still finding the source (a signed URL being minted, bytes being
|
|
91
|
+
* fetched). It is the difference between "on its way" and "cannot be shown", and only the
|
|
92
|
+
* caller knows which.
|
|
93
|
+
*/
|
|
94
|
+
resolving?: boolean;
|
|
81
95
|
name?: string;
|
|
82
96
|
/**
|
|
83
97
|
* The clip's length, when the app already knows it. The element's own metadata still wins
|
|
@@ -102,6 +116,7 @@ export interface VideoCutEditorProps {
|
|
|
102
116
|
export function VideoCutEditor({
|
|
103
117
|
src,
|
|
104
118
|
posterUrl,
|
|
119
|
+
resolving = false,
|
|
105
120
|
name,
|
|
106
121
|
durationSeconds,
|
|
107
122
|
open = true,
|
|
@@ -118,6 +133,25 @@ export function VideoCutEditor({
|
|
|
118
133
|
const [plan, setPlan] = React.useState<CutPlan>(EMPTY_PLAN);
|
|
119
134
|
const [submitting, setSubmitting] = React.useState(false);
|
|
120
135
|
const [error, setError] = React.useState<string | null>(null);
|
|
136
|
+
// A source that changes is a fresh attempt: it must not inherit the previous one's failure,
|
|
137
|
+
// the same rule `VideoTurner` and the gallery's own `ThumbImg` keep.
|
|
138
|
+
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Why there is no film to mark up — `null` when there is one.
|
|
142
|
+
*
|
|
143
|
+
* 🔴 The dialog SAYS it rather than rendering the black rectangle a source-less `<video>`
|
|
144
|
+
* paints. Marking a cut means scrubbing to a moment, so with nothing to play there is
|
|
145
|
+
* genuinely nothing to mark — and the one thing worse than that is a player that looks
|
|
146
|
+
* broken and a Send button whose refusal ("Nothing is marked") reads as the dialog's fault.
|
|
147
|
+
*/
|
|
148
|
+
const cannotShow: string | null = resolving
|
|
149
|
+
? 'Finding this film…'
|
|
150
|
+
: !src
|
|
151
|
+
? 'This file has no video source to play here, so there is nothing to mark up. Nothing has been sent.'
|
|
152
|
+
: failedSrc === src
|
|
153
|
+
? 'The film could not be loaded, so there is nothing to mark up. Nothing has been sent.'
|
|
154
|
+
: null;
|
|
121
155
|
|
|
122
156
|
const segments = React.useMemo(() => planSegments(plan, duration), [plan, duration]);
|
|
123
157
|
const cuts = React.useMemo(() => planCuts(plan, duration), [plan, duration]);
|
|
@@ -248,23 +282,34 @@ export function VideoCutEditor({
|
|
|
248
282
|
onKeyDown={onKeyDown}
|
|
249
283
|
aria-label='Mark sections to cut. Press [ to start a cut, ] to end it, space to play or pause.'
|
|
250
284
|
>
|
|
251
|
-
{
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
285
|
+
{cannotShow === null ? (
|
|
286
|
+
// biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions.
|
|
287
|
+
<video
|
|
288
|
+
ref={videoRef}
|
|
289
|
+
className='cbvc-video'
|
|
290
|
+
// A new source is a new attempt — see `failedSrc`.
|
|
291
|
+
key={src}
|
|
292
|
+
src={src}
|
|
293
|
+
poster={posterUrl}
|
|
294
|
+
controls
|
|
295
|
+
playsInline
|
|
296
|
+
preload='metadata'
|
|
297
|
+
onLoadedMetadata={(event) => {
|
|
298
|
+
const seen = event.currentTarget.duration;
|
|
299
|
+
// The element's own metadata wins over anything the catalog rounded.
|
|
300
|
+
if (Number.isFinite(seen) && seen > 0) setDuration(seen);
|
|
301
|
+
}}
|
|
302
|
+
onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
303
|
+
onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
304
|
+
onError={() => setFailedSrc(src)}
|
|
305
|
+
/>
|
|
306
|
+
) : (
|
|
307
|
+
/* In the film's own place and at its own size — a sentence under a black rectangle
|
|
308
|
+
would leave the rectangle there to be reported as a black screen. */
|
|
309
|
+
<p className='cbvc-video cbvc-nofilm' role='status'>
|
|
310
|
+
{cannotShow}
|
|
311
|
+
</p>
|
|
312
|
+
)}
|
|
268
313
|
|
|
269
314
|
<div className='cbvc-marks'>
|
|
270
315
|
<Button size='sm' variant='secondary' onClick={cutHere} tooltip='Cut the clip here (C)'>
|
|
@@ -132,6 +132,89 @@ describe('ProcessingIndicator', () => {
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* 🔴 The second corner badge: this play is the progressive fallback and the app KNOWS it,
|
|
137
|
+
* because its probe of the media store did not answer.
|
|
138
|
+
*
|
|
139
|
+
* Why it exists at all: `collections` answers `/api/items/:id/url` with `playbackDegraded`, and
|
|
140
|
+
* until this badge the fact surfaced in exactly two places — the field and one stderr line,
|
|
141
|
+
* neither of them on screen. The fallback is silent and expensive (a 467 MB average `.web.mp4`
|
|
142
|
+
* that Cloudflare refuses to cache against a ~1 MB edge-served ladder segment), so for eight
|
|
143
|
+
* days the only detector in the fleet was the owner watching a video stall.
|
|
144
|
+
*
|
|
145
|
+
* 🔴 These assertions are about WHICH SENTENCE is on screen, which is the whole risk here. The
|
|
146
|
+
* cheap implementation — map `playbackDegraded` onto `processingStatus: 'failed'` and reuse the
|
|
147
|
+
* badge that already exists — puts "Optimization failed" over a video whose optimization
|
|
148
|
+
* succeeded. Hence a test that the degraded badge appears WITHOUT that text, and a test that
|
|
149
|
+
* both sentences survive together.
|
|
150
|
+
*/
|
|
151
|
+
describe('🔴 the badge for a ladder we could not reach', () => {
|
|
152
|
+
// 🔴 Both sentences spelled out, not imported from the component. Which words are on the
|
|
153
|
+
// picture IS the deliverable here, and an assertion that reads the component's own constant
|
|
154
|
+
// stays green through a rewrite of the thing it exists to pin.
|
|
155
|
+
const DEGRADED = 'Reduced quality — could not reach the media store';
|
|
156
|
+
const OPTIMIZATION_FAILED = 'Optimization failed — playing original';
|
|
157
|
+
const textOf = (nodes: HTMLElement[]) => nodes.map((n) => n.textContent ?? '');
|
|
158
|
+
|
|
159
|
+
test('a degraded play says so — and does NOT claim the optimization failed', () => {
|
|
160
|
+
const { getAllByRole } = render(<ProcessingIndicator playbackDegraded />);
|
|
161
|
+
const lines = textOf(getAllByRole('status'));
|
|
162
|
+
expect(lines).toEqual([DEGRADED]);
|
|
163
|
+
expect(lines.join(' ')).not.toContain('Optimization failed');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a video with NO ladder and nothing wrong draws nothing at all', () => {
|
|
167
|
+
// The ordinary case: no ladder was ever built, `streamUrl` plays, there is no fault. A
|
|
168
|
+
// badge here would appear on most of the library and stop meaning anything.
|
|
169
|
+
const { container } = render(<VideoPlayer video={video} />);
|
|
170
|
+
expect(container.querySelector('[role="status"]')).toBeNull();
|
|
171
|
+
expect(container.querySelector('[role="progressbar"]')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('the flag on the clip is what puts it on the player', () => {
|
|
175
|
+
const { getAllByRole } = render(<VideoPlayer video={{ ...video, playbackDegraded: true }} />);
|
|
176
|
+
expect(textOf(getAllByRole('status'))).toEqual([DEGRADED]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('…and on the canvas fallback, which shares the indicator', () => {
|
|
180
|
+
const { getAllByRole } = render(
|
|
181
|
+
<CanvasVideoPlayer video={{ ...video, playbackDegraded: true }} />,
|
|
182
|
+
);
|
|
183
|
+
expect(textOf(getAllByRole('status'))).toEqual([DEGRADED]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/*
|
|
187
|
+
* 🔴 Both at once is a real state — a probe that did not answer about a video whose
|
|
188
|
+
* optimization also failed — and it is the state the first draft of any corner badge gets
|
|
189
|
+
* wrong: two pills pinned to `top: 8; right: 8` draw one exactly on top of the other. They
|
|
190
|
+
* are two sentences in ONE stack, which is what `VideoStatusBadges` exists for.
|
|
191
|
+
*/
|
|
192
|
+
test('a failed optimization AND an unreachable store are TWO pills, in one stack', () => {
|
|
193
|
+
const { getAllByRole } = render(
|
|
194
|
+
<VideoPlayer video={{ ...video, processingStatus: 'failed', playbackDegraded: true }} />,
|
|
195
|
+
);
|
|
196
|
+
const pills = getAllByRole('status');
|
|
197
|
+
expect(textOf(pills)).toEqual([OPTIMIZATION_FAILED, DEGRADED]);
|
|
198
|
+
// One positioned parent holding both, so the second can never be hidden under the first.
|
|
199
|
+
expect(pills[0]?.parentElement).toBe(pills[1]?.parentElement as HTMLElement);
|
|
200
|
+
const stack = pills[0]?.parentElement as HTMLElement;
|
|
201
|
+
expect(stack.style.position).toBe('absolute');
|
|
202
|
+
expect(stack.style.flexDirection).toBe('column');
|
|
203
|
+
expect(pills[0]?.style.position).toBe('');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('still optimizing AND degraded keeps the bar as well as the badge', () => {
|
|
207
|
+
// The bar is work in flight; the badge is the transport. Neither replaces the other.
|
|
208
|
+
const { getAllByRole, getByRole } = render(
|
|
209
|
+
<VideoPlayer
|
|
210
|
+
video={{ ...video, processingStatus: 'processing', playbackDegraded: true }}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
expect(textOf(getAllByRole('status'))).toEqual([DEGRADED]);
|
|
214
|
+
expect(getByRole('progressbar', { name: 'Optimizing video' })).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
135
218
|
describe('autoplay — a video opened as the SUBJECT plays on arrival, silently', () => {
|
|
136
219
|
/*
|
|
137
220
|
* 🔴 Asserted on the pure `resolveAutoplay` rather than on the rendered player, because
|
|
@@ -569,3 +652,21 @@ describe('🔴 hls.js is served from this bundle, never from a CDN', () => {
|
|
|
569
652
|
expect(source).toContain("() => import('hls.js')");
|
|
570
653
|
});
|
|
571
654
|
});
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 🔴 The shared player's half of *"make a 404 stop the media player spinner so I don't think
|
|
658
|
+
* it's going to eventually load"* (owner, 2026-09-17).
|
|
659
|
+
*
|
|
660
|
+
* The decision and the sentence live in `sourceFault.tsx` and are pinned by its spec — this is
|
|
661
|
+
* the one claim that belongs to the PLAYER: that a player nobody has answered draws neither.
|
|
662
|
+
* Vidstack's own element is a custom element happy-dom never brings to life, which is the same
|
|
663
|
+
* reason `stagePlayableVideo` is asserted as a mapping rather than through the DOM.
|
|
664
|
+
*/
|
|
665
|
+
describe('🔴 an answered source is not a slow one', () => {
|
|
666
|
+
test('a player nobody has answered draws no overlay at all', () => {
|
|
667
|
+
// The ordinary case, and the one that must stay untouched: nothing has failed, so the
|
|
668
|
+
// picture is the picture.
|
|
669
|
+
const { queryByRole } = render(<VideoPlayer video={video} />);
|
|
670
|
+
expect(queryByRole('status')).toBeNull();
|
|
671
|
+
});
|
|
672
|
+
});
|