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.
Files changed (81) hide show
  1. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  2. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  3. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  4. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  5. package/dist/react/media/FrameGrabber.d.ts +16 -2
  6. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  7. package/dist/react/media/FrameGrabber.js +68 -43
  8. package/dist/react/media/FrameGrabber.js.map +1 -1
  9. package/dist/react/media/PictureEditor.d.ts +17 -2
  10. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  11. package/dist/react/media/PictureEditor.js +33 -7
  12. package/dist/react/media/PictureEditor.js.map +1 -1
  13. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  14. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  15. package/dist/react/media/VideoChapterEditor.js +21 -3
  16. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  17. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  18. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  19. package/dist/react/media/VideoCutEditor.js +21 -3
  20. package/dist/react/media/VideoCutEditor.js.map +1 -1
  21. package/dist/react/media/VideoPlayer.d.ts +37 -9
  22. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  23. package/dist/react/media/VideoPlayer.js +130 -39
  24. package/dist/react/media/VideoPlayer.js.map +1 -1
  25. package/dist/react/media/hlsSource.d.ts +24 -3
  26. package/dist/react/media/hlsSource.d.ts.map +1 -1
  27. package/dist/react/media/hlsSource.js +109 -8
  28. package/dist/react/media/hlsSource.js.map +1 -1
  29. package/dist/react/media/pictureEditor.css +2 -0
  30. package/dist/react/media/sourceFault.d.ts +146 -0
  31. package/dist/react/media/sourceFault.d.ts.map +1 -0
  32. package/dist/react/media/sourceFault.js +139 -0
  33. package/dist/react/media/sourceFault.js.map +1 -0
  34. package/dist/react/media/videoChapterEditor.css +19 -0
  35. package/dist/react/media/videoCutEditor.css +22 -0
  36. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  37. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  38. package/dist/react/media-gallery/MediaGallery.js +166 -13
  39. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  40. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  41. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  42. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  43. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  44. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  45. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  46. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  47. package/dist/react/media-gallery/types.d.ts +23 -0
  48. package/dist/react/media-gallery/types.d.ts.map +1 -1
  49. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  50. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  51. package/dist/styles-areas/media-gallery.css +1 -1
  52. package/dist/styles-areas/media.css +1 -1
  53. package/package.json +3 -2
  54. package/scripts/guardrailsEnforce.spec.ts +244 -1
  55. package/scripts/guardrailsEnforce.ts +224 -7
  56. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  57. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  58. package/src/react/media/FrameGrabber.tsx +138 -84
  59. package/src/react/media/PictureEditor.spec.tsx +90 -0
  60. package/src/react/media/PictureEditor.tsx +78 -23
  61. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  62. package/src/react/media/VideoChapterEditor.tsx +64 -18
  63. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  64. package/src/react/media/VideoCutEditor.tsx +63 -18
  65. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  66. package/src/react/media/VideoPlayer.tsx +221 -73
  67. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  68. package/src/react/media/hlsSource.ts +136 -8
  69. package/src/react/media/pictureEditor.css +2 -0
  70. package/src/react/media/sourceFault.spec.tsx +224 -0
  71. package/src/react/media/sourceFault.tsx +220 -0
  72. package/src/react/media/videoChapterEditor.css +19 -0
  73. package/src/react/media/videoCutEditor.css +22 -0
  74. package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
  75. package/src/react/media-gallery/MediaGallery.tsx +356 -45
  76. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  77. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  78. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  79. package/src/react/media-gallery/types.ts +23 -0
  80. package/src/styles-areas/media-gallery.css +1 -1
  81. 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
- /** The clip, as a URL a `<video>` can play. */
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
- {/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions. */}
269
- <video
270
- ref={videoRef}
271
- className='cbch-video'
272
- src={src}
273
- poster={posterUrl}
274
- controls
275
- playsInline
276
- preload='metadata'
277
- onLoadedMetadata={(event) => {
278
- const seen = event.currentTarget.duration;
279
- // The element's own metadata wins over anything the catalog rounded.
280
- if (Number.isFinite(seen) && seen > 0) setDuration(seen);
281
- }}
282
- onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
283
- onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
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
- /** The clip, as a URL a `<video>` can play. */
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
- {/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions. */}
252
- <video
253
- ref={videoRef}
254
- className='cbvc-video'
255
- src={src}
256
- poster={posterUrl}
257
- controls
258
- playsInline
259
- preload='metadata'
260
- onLoadedMetadata={(event) => {
261
- const seen = event.currentTarget.duration;
262
- // The element's own metadata wins over anything the catalog rounded.
263
- if (Number.isFinite(seen) && seen > 0) setDuration(seen);
264
- }}
265
- onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
266
- onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
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
+ });