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
|
@@ -71,6 +71,39 @@
|
|
|
71
71
|
color: var(--accent-solid-foreground);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/* ── Icon-only pills ───────────────────────────────────────────────────────────────────
|
|
75
|
+
*
|
|
76
|
+
* Owner, `ideas/collections-09-14.md`: *"Make it have icons only, instead of words in the
|
|
77
|
+
* strip of tools … Hovering over the tooltip can show the words."* Every pill in that strip
|
|
78
|
+
* already carried a `Tooltip` and most an `aria-label`, so the words were the redundant
|
|
79
|
+
* half — what this adds is the BOX an iconless pill needs.
|
|
80
|
+
*
|
|
81
|
+
* 🔴 The tap floor is the thing an icon-only strip loses first, and it is lost on the WIDTH,
|
|
82
|
+
* not the height. `.cbgd-order-btn` above only ever set `min-height`, because a pill with a
|
|
83
|
+
* word in it is wide by construction; strip the word and `padding-inline` is all that is
|
|
84
|
+
* left, which on a coarse pointer is a 42px-wide target under a 44px rule. So `min-width`
|
|
85
|
+
* is declared in all three places `min-height` is — see the two media queries at the foot of
|
|
86
|
+
* this file — and `e2e/touch-targets.spec.ts` measures the rendered rectangles rather than
|
|
87
|
+
* trusting this comment. cursedbelt has shipped this exact defect once already
|
|
88
|
+
* (`Checkbox`, task 69).
|
|
89
|
+
*
|
|
90
|
+
* `svg` rather than a class on the glyph: the pill is handed a lucide element by the gallery
|
|
91
|
+
* and by any consumer filling `detailActions`, and lucide sizes itself with width/height
|
|
92
|
+
* ATTRIBUTES (24 by default) that a CSS rule overrides. Sizing here is therefore the one
|
|
93
|
+
* place that cannot be forgotten at a call site. */
|
|
94
|
+
.cbgd-order-btn.is-icon {
|
|
95
|
+
min-width: 28px;
|
|
96
|
+
padding-inline: 7px;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
}
|
|
99
|
+
.cbgd-order-btn svg {
|
|
100
|
+
width: 15px;
|
|
101
|
+
height: 15px;
|
|
102
|
+
/* A glyph must never be the thing that shrinks when the row runs out of room — the pill
|
|
103
|
+
is the flex item that gives way, and a squashed icon is unreadable rather than tight. */
|
|
104
|
+
flex: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
/* ── The slideshow speed control ────────────────────────────────────────────────────────
|
|
75
108
|
*
|
|
76
109
|
* A `<label>` wrapping a `<select>`, shaped as one more action pill so the actions row stays
|
|
@@ -99,6 +132,14 @@
|
|
|
99
132
|
.cbgd-dwell-label {
|
|
100
133
|
line-height: 1;
|
|
101
134
|
}
|
|
135
|
+
/* The label is an hourglass now rather than the word "Hold" (2026-09-16) — same 15px as the
|
|
136
|
+
pills beside it, and `block` so the line box does not add a descender's worth of slack
|
|
137
|
+
under a glyph that has none. The VALUE (`5s`) stays as text in the select. */
|
|
138
|
+
.cbgd-dwell-label svg {
|
|
139
|
+
width: 15px;
|
|
140
|
+
height: 15px;
|
|
141
|
+
display: block;
|
|
142
|
+
}
|
|
102
143
|
.cbgd-dwell-select {
|
|
103
144
|
min-height: 24px;
|
|
104
145
|
padding: 0 4px;
|
|
@@ -408,56 +449,51 @@
|
|
|
408
449
|
height: var(--cbgd-stage-h, var(--cbgd-stage-fit, var(--cbgd-stage-default)));
|
|
409
450
|
max-width: calc(var(--cbgd-stage-h, var(--cbgd-stage-fit, var(--cbgd-stage-default))) * 16 / 9);
|
|
410
451
|
margin: 0 auto;
|
|
411
|
-
/*
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
HISTORY: the big overlay ⟨ ⟩ step arrows
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
452
|
+
/* 🔴 Nothing of OURS rides on the frame any more, and that is the 2026-09-14 fix.
|
|
453
|
+
`position: relative` stays because a stage may be a player that positions its own
|
|
454
|
+
layers against it, and because `.cbgd-stage-img` letterboxes inside it.
|
|
455
|
+
HISTORY: the big overlay ⟨ ⟩ step arrows hung here until 2026-08-25 — owner: *"Remove
|
|
456
|
+
the big previous and next buttons off the sides of the video."* Stepping moved into
|
|
457
|
+
`.cbgd-watch-step` in the titlebar, and for video into the player's own control bar.
|
|
458
|
+
The resize grip (`.cbmr-corner`) hung here until 2026-09-16 — owner: *"It should only
|
|
459
|
+
be draggable when my mouse pointer is at the bottom right corner … just like mac
|
|
460
|
+
windows"* — and is now in `.cbmr-rail`, a sibling BELOW this box. Do not put either
|
|
461
|
+
back on the frame: everything drawn here competes with whatever the stage is, and for
|
|
462
|
+
a video that is a full-bleed control bar. */
|
|
418
463
|
position: relative;
|
|
419
464
|
}
|
|
420
465
|
/* Whatever the stage turns out to be — a player, a letterboxed picture, a document
|
|
421
|
-
panel, an audio card, the loading placeholder — it is exactly the frame.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
which is what was deciding it. */
|
|
426
|
-
.cbgd-watch-player-box > *:not(.cbmr-corner) {
|
|
466
|
+
panel, an audio card, the loading placeholder — it is exactly the frame. Nothing but
|
|
467
|
+
the stage is in this box now: the resize grip left it on 2026-09-16 for the rail
|
|
468
|
+
below (`mediaResize.css`), which is why this no longer excludes `.cbmr-corner`. */
|
|
469
|
+
.cbgd-watch-player-box > * {
|
|
427
470
|
height: 100%;
|
|
428
471
|
min-height: 0;
|
|
429
472
|
}
|
|
430
473
|
|
|
431
|
-
/* ──
|
|
432
|
-
*
|
|
433
|
-
*
|
|
474
|
+
/* ── 🔴 The control bar reserves NOTHING, and that is the 2026-09-14 fix ──────
|
|
475
|
+
* There was a gutter here — `padding-inline-end: var(--cbmr-corner-size)` on the
|
|
476
|
+
* player's last two `.vds-controls-group`s — so the buttons would not lay out under a
|
|
477
|
+
* 64px grip that hung inside this frame's bottom-right corner. It is deleted, along
|
|
478
|
+
* with the overlay that needed it.
|
|
434
479
|
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* of the containment values that opens a stacking context. So the player's control
|
|
439
|
-
* layer competes DIRECTLY with `.cbmr-corner` in this frame's stacking context and
|
|
440
|
-
* wins on 10 > 3. Its bottom `.vds-controls-group` is `width:100%` with
|
|
441
|
-
* `pointer-events:auto`, so the whole bottom strip of the frame — including the empty
|
|
442
|
-
* space between the cog and the frame's edge — swallowed the grip's pointer events.
|
|
443
|
-
* And it does so only when the bar is SHOWING, i.e. on hover, i.e. at the exact
|
|
444
|
-
* moment somebody reaches for the corner. That is the whole of the report.
|
|
480
|
+
* Owner, `ideas/collections-09-14.md`: *"Fix the drag to resize area to no longer
|
|
481
|
+
* overlap part of the horizontal play timeline area that shows what part of the video
|
|
482
|
+
* is playing."*
|
|
445
483
|
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
484
|
+
* 🔴 The gutter was not the bug — measured, in a browser, before it was touched: with
|
|
485
|
+
* it in place the corner's rect really did clear `.vds-time-slider`'s. What it could
|
|
486
|
+
* not do was reach `.cbmr-bar`, which ran the full width UNDER the slider rather than
|
|
487
|
+
* beside it; and it can only ever shorten a control bar this package styles, which a
|
|
488
|
+
* `nativeVideoStage` gallery's `<video controls>` is not. It also cost 64px of scrub
|
|
489
|
+
* bar to buy a corner, which is the other half of the sentence above.
|
|
451
490
|
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
491
|
+
* So the grips moved out of the frame instead (`mediaResize.css`) and the slider gets
|
|
492
|
+
* its full width back. `e2e/resize-grip-rail.spec.ts` measures every drag surface
|
|
493
|
+
* against the frame and against the slider, in a real player, and fails if any of them
|
|
494
|
+
* touches either; do not reintroduce a gutter here to "make room" for something drawn
|
|
495
|
+
* on the picture — put the something in the rail.
|
|
456
496
|
*/
|
|
457
|
-
.cbgd-watch-player-box [data-media-player]:not([data-fullscreen]) .vds-controls-group:last-child,
|
|
458
|
-
.cbgd-watch-player-box [data-media-player]:not([data-fullscreen]) .vds-controls-group:nth-last-child(2) {
|
|
459
|
-
padding-inline-end: var(--cbmr-corner-size, 64px);
|
|
460
|
-
}
|
|
461
497
|
|
|
462
498
|
/* ── Stepping in the titlebar ────────────────────────────────────────────────
|
|
463
499
|
* The ⟨ ⟩ overlay pills that used to sit on the left and right edges of the frame are
|
|
@@ -471,7 +507,9 @@
|
|
|
471
507
|
gap: 2px;
|
|
472
508
|
margin-inline-end: 4px;
|
|
473
509
|
}
|
|
474
|
-
/* The glyph is a single chevron and would otherwise sit in a pill sized for a word.
|
|
510
|
+
/* The glyph is a single chevron and would otherwise sit in a pill sized for a word. Wider
|
|
511
|
+
than `.is-icon`'s 28px because these two are the pair a reader hits over and over while
|
|
512
|
+
walking a folder, and they are the only pills whose job is repetition. */
|
|
475
513
|
.cbgd-watch-step .cbgd-order-btn {
|
|
476
514
|
min-width: 34px;
|
|
477
515
|
padding-inline: 8px;
|
|
@@ -637,6 +675,11 @@
|
|
|
637
675
|
min-height: 44px;
|
|
638
676
|
padding: 4px 14px;
|
|
639
677
|
}
|
|
678
|
+
/* The width half of the same floor — see `.is-icon` at the top of this file. */
|
|
679
|
+
.cbgd-order-btn.is-icon {
|
|
680
|
+
min-width: 44px;
|
|
681
|
+
padding-inline: 10px;
|
|
682
|
+
}
|
|
640
683
|
.cbgd-dwell {
|
|
641
684
|
min-height: 44px;
|
|
642
685
|
}
|
|
@@ -662,6 +705,18 @@
|
|
|
662
705
|
.cbgd-order-btn {
|
|
663
706
|
min-height: 44px;
|
|
664
707
|
}
|
|
708
|
+
/* 🔴 The width, which is what an icon-only pill is missing — a 15px glyph in 2×7px of
|
|
709
|
+
padding is a 29px target on a phone however tall the pill is. */
|
|
710
|
+
.cbgd-order-btn.is-icon {
|
|
711
|
+
min-width: 44px;
|
|
712
|
+
padding-inline: 10px;
|
|
713
|
+
}
|
|
714
|
+
/* Bigger glyph to match the bigger target: 15px reads as a speck at arm's length. */
|
|
715
|
+
.cbgd-order-btn svg,
|
|
716
|
+
.cbgd-dwell-label svg {
|
|
717
|
+
width: 18px;
|
|
718
|
+
height: 18px;
|
|
719
|
+
}
|
|
665
720
|
.cbgd-search {
|
|
666
721
|
min-height: 44px;
|
|
667
722
|
}
|
|
@@ -673,12 +728,15 @@
|
|
|
673
728
|
}
|
|
674
729
|
}
|
|
675
730
|
|
|
676
|
-
/* ── The stage resize
|
|
677
|
-
*
|
|
678
|
-
* shared with the focus viewer since 2026-08-25
|
|
679
|
-
* here was reported as a feature that did not exist. Only
|
|
680
|
-
* module's business, because only this module knows the frame is 16:9-capped
|
|
681
|
-
|
|
731
|
+
/* ── The stage resize rail ───────────────────────────────────────────────────
|
|
732
|
+
* It is `mediaResize.css`'s (`.cbmr-rail`, holding the corner grip and — on coarse
|
|
733
|
+
* pointers — the bar), shared with the focus viewer since 2026-08-25, the day a handle
|
|
734
|
+
* that existed only here was reported as a feature that did not exist. Only its WIDTH is
|
|
735
|
+
* this module's business, because only this module knows the frame is 16:9-capped: the
|
|
736
|
+
* rail has to end exactly where the picture does, or its bottom-right corner is not the
|
|
737
|
+
* picture's bottom-right corner and *"the bottom right corner of the video player"* stops
|
|
738
|
+
* being true. */
|
|
739
|
+
.cbgd-watch-player > .cbmr-rail {
|
|
682
740
|
max-width: calc(var(--cbgd-stage-h, var(--cbgd-stage-fit, var(--cbgd-stage-default))) * 16 / 9);
|
|
683
741
|
margin-inline: auto;
|
|
684
742
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { attachHlsSource, HLS_CONFIG, RECOVERY_BUDGET } from './hlsSource';
|
|
3
|
+
|
|
4
|
+
/** A `<video>` that reports no native HLS, so every test here takes the hls.js branch. */
|
|
5
|
+
function videoEl(canPlayHls = false): HTMLVideoElement {
|
|
6
|
+
const el = document.createElement('video');
|
|
7
|
+
el.canPlayType = ((type: string) =>
|
|
8
|
+
canPlayHls && type === 'application/vnd.apple.mpegurl' ? 'maybe' : '') as HTMLVideoElement['canPlayType'];
|
|
9
|
+
return el;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LADDER = 'https://example.test/clip.hls/master.m3u8';
|
|
13
|
+
const REMUX = 'https://example.test/clip.web.mp4';
|
|
14
|
+
|
|
15
|
+
//
|
|
16
|
+
// *"sometimes a video is playing and starts buffering and ends up pausing instead of trying
|
|
17
|
+
// to wait it out"* (2026-09-17). An hls.js with no ERROR listener stops permanently on any
|
|
18
|
+
// fatal error — the library retries a fragment six times, gives up, emits fatal, and the
|
|
19
|
+
// application is expected to decide what happens next. This module listened to nothing, so
|
|
20
|
+
// "what happens next" was nothing, for ever.
|
|
21
|
+
//
|
|
22
|
+
// These specs drive a FAKE hls.js because the real one needs MediaSource, which happy-dom
|
|
23
|
+
// does not have — and because what is under test is our decision tree, not the library's.
|
|
24
|
+
|
|
25
|
+
/** The calls a test wants to assert on, shared by every instance the fake constructs. */
|
|
26
|
+
const calls: string[] = [];
|
|
27
|
+
let emit: ((data: { fatal?: boolean; type?: string; details?: string }) => void) | null = null;
|
|
28
|
+
let configSeen: Record<string, unknown> | null = null;
|
|
29
|
+
let supported = true;
|
|
30
|
+
|
|
31
|
+
function installFakeHls(): void {
|
|
32
|
+
class Hls {
|
|
33
|
+
static Events = { ERROR: 'hlsError' } as const;
|
|
34
|
+
static ErrorTypes = { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError', MUX_ERROR: 'muxError' } as const;
|
|
35
|
+
static isSupported = (): boolean => supported;
|
|
36
|
+
constructor(config: Record<string, unknown>) {
|
|
37
|
+
configSeen = config;
|
|
38
|
+
}
|
|
39
|
+
on(_event: string, handler: (e: unknown, d: { fatal?: boolean; type?: string }) => void): void {
|
|
40
|
+
emit = (d) => handler('hlsError', d);
|
|
41
|
+
}
|
|
42
|
+
loadSource(url: string): void {
|
|
43
|
+
calls.push(`loadSource:${url}`);
|
|
44
|
+
}
|
|
45
|
+
attachMedia(): void {
|
|
46
|
+
calls.push('attachMedia');
|
|
47
|
+
}
|
|
48
|
+
startLoad(): void {
|
|
49
|
+
calls.push('startLoad');
|
|
50
|
+
}
|
|
51
|
+
recoverMediaError(): void {
|
|
52
|
+
calls.push('recoverMediaError');
|
|
53
|
+
}
|
|
54
|
+
swapAudioCodec(): void {
|
|
55
|
+
calls.push('swapAudioCodec');
|
|
56
|
+
}
|
|
57
|
+
destroy(): void {
|
|
58
|
+
calls.push('destroy');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
mock.module('hls.js', () => ({ default: Hls }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Let the dynamic import resolve and the `.then` run. */
|
|
65
|
+
const settle = async (): Promise<void> => {
|
|
66
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe('attachHlsSource — recovering instead of stopping', () => {
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
calls.length = 0;
|
|
72
|
+
emit = null;
|
|
73
|
+
configSeen = null;
|
|
74
|
+
supported = true;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('🔴 a fatal network error reloads rather than leaving the video stopped', async () => {
|
|
78
|
+
installFakeHls();
|
|
79
|
+
const el = videoEl(false);
|
|
80
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
81
|
+
await settle();
|
|
82
|
+
expect(calls).toContain(`loadSource:${LADDER}`);
|
|
83
|
+
|
|
84
|
+
emit?.({ fatal: true, type: 'networkError', details: 'fragLoadError' });
|
|
85
|
+
// The first retry is immediate; later ones back off. Either way it must actually happen.
|
|
86
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
87
|
+
expect(calls).toContain('startLoad');
|
|
88
|
+
// And it did NOT abandon the ladder on the first blip — that is the whole point.
|
|
89
|
+
expect(el.src).not.toBe(REMUX);
|
|
90
|
+
detach();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a NON-fatal error is hls.js retrying on its own and must not be touched', async () => {
|
|
94
|
+
installFakeHls();
|
|
95
|
+
const el = videoEl(false);
|
|
96
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
97
|
+
await settle();
|
|
98
|
+
emit?.({ fatal: false, type: 'networkError', details: 'fragLoadError' });
|
|
99
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
100
|
+
expect(calls).not.toContain('startLoad');
|
|
101
|
+
detach();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a media error recovers, and the SECOND one swaps the audio codec', async () => {
|
|
105
|
+
// hls.js's own guidance: calling recoverMediaError() twice for one stall recovers
|
|
106
|
+
// nothing. The codec swap is what clears the recurring case.
|
|
107
|
+
installFakeHls();
|
|
108
|
+
const el = videoEl(false);
|
|
109
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
110
|
+
await settle();
|
|
111
|
+
emit?.({ fatal: true, type: 'mediaError' });
|
|
112
|
+
expect(calls.filter((c) => c === 'recoverMediaError').length).toBe(1);
|
|
113
|
+
expect(calls).not.toContain('swapAudioCodec');
|
|
114
|
+
emit?.({ fatal: true, type: 'mediaError' });
|
|
115
|
+
expect(calls).toContain('swapAudioCodec');
|
|
116
|
+
expect(calls.filter((c) => c === 'recoverMediaError').length).toBe(2);
|
|
117
|
+
detach();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('🔴 the recovery budget is spent, then the progressive copy takes over', async () => {
|
|
121
|
+
installFakeHls();
|
|
122
|
+
const el = videoEl(false);
|
|
123
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
124
|
+
await settle();
|
|
125
|
+
for (let i = 0; i <= RECOVERY_BUDGET; i++) emit?.({ fatal: true, type: 'mediaError' });
|
|
126
|
+
expect(calls).toContain('destroy');
|
|
127
|
+
expect(el.src).toBe(REMUX);
|
|
128
|
+
detach();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('🔴 the fallback resumes where playback was, not from zero', async () => {
|
|
132
|
+
// A fallback forty minutes into a film that restarts it from the beginning is a fallback
|
|
133
|
+
// the owner would rather not have had — and it happens exactly when things are already
|
|
134
|
+
// going badly.
|
|
135
|
+
installFakeHls();
|
|
136
|
+
const el = videoEl(false);
|
|
137
|
+
Object.defineProperty(el, 'currentTime', { value: 2400, writable: true, configurable: true });
|
|
138
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
139
|
+
await settle();
|
|
140
|
+
for (let i = 0; i <= RECOVERY_BUDGET; i++) emit?.({ fatal: true, type: 'mediaError' });
|
|
141
|
+
expect(el.src).toBe(REMUX);
|
|
142
|
+
el.dispatchEvent(new Event('loadedmetadata'));
|
|
143
|
+
expect(el.currentTime).toBe(2400);
|
|
144
|
+
detach();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('an unrecognised fatal error goes straight to the remux — a reload does not fix a mux error', async () => {
|
|
148
|
+
installFakeHls();
|
|
149
|
+
const el = videoEl(false);
|
|
150
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
151
|
+
await settle();
|
|
152
|
+
emit?.({ fatal: true, type: 'muxError' });
|
|
153
|
+
expect(el.src).toBe(REMUX);
|
|
154
|
+
expect(calls).not.toContain('startLoad');
|
|
155
|
+
detach();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('🔴 a teardown cancels a retry that has not fired yet', async () => {
|
|
159
|
+
// The backoff means a retry can be in a timer when React unmounts the stage. Firing it
|
|
160
|
+
// would call into a destroyed engine — and keep a timer alive on a dead element.
|
|
161
|
+
installFakeHls();
|
|
162
|
+
const el = videoEl(false);
|
|
163
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
164
|
+
await settle();
|
|
165
|
+
emit?.({ fatal: true, type: 'networkError' }); // attempt 0 — no backoff
|
|
166
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
167
|
+
expect(calls.filter((c) => c === 'startLoad').length).toBe(1);
|
|
168
|
+
|
|
169
|
+
emit?.({ fatal: true, type: 'networkError' }); // attempt 1 — 1 s backoff, still pending
|
|
170
|
+
detach();
|
|
171
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
172
|
+
// Still one. The pending retry was cancelled rather than firing into a destroyed engine.
|
|
173
|
+
expect(calls.filter((c) => c === 'startLoad').length).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('an unsupported hls.js still lands on the remux, and never builds an engine', async () => {
|
|
177
|
+
installFakeHls();
|
|
178
|
+
supported = false;
|
|
179
|
+
const el = videoEl(false);
|
|
180
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
181
|
+
await settle();
|
|
182
|
+
expect(el.src).toBe(REMUX);
|
|
183
|
+
expect(calls).toEqual([]);
|
|
184
|
+
detach();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('HLS_CONFIG — tuned for a home Mac behind a tunnel, not a CDN', () => {
|
|
189
|
+
test('🔴 the forward buffer is far wider than hls.js\'s 30 s default', () => {
|
|
190
|
+
// A cold segment is up to 2.6 s for 4 s of video (measured 2026-09-17), so 30 s of buffer
|
|
191
|
+
// survives about a dozen slow fetches back to back. That is the stall being reported.
|
|
192
|
+
expect(HLS_CONFIG.maxBufferLength).toBeGreaterThanOrEqual(60);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('🔴 the starting bandwidth estimate is not the 500 kbit stranger default', () => {
|
|
196
|
+
// Everyone here is on the household network and the top rung is capped at 5,000 kbit by
|
|
197
|
+
// the encoder ladder. Starting low means the first thing anyone sees is 480p.
|
|
198
|
+
expect(HLS_CONFIG.abrEwmaDefaultEstimate).toBeGreaterThanOrEqual(3_000_000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('the back buffer is bounded — unbounded is how a long film loses its MediaSource', () => {
|
|
202
|
+
expect(HLS_CONFIG.backBufferLength).toBeGreaterThan(0);
|
|
203
|
+
expect(HLS_CONFIG.backBufferLength).toBeLessThanOrEqual(120);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the config actually reaches the engine', async () => {
|
|
207
|
+
installFakeHls();
|
|
208
|
+
const detach = attachHlsSource(videoEl(false), { hlsUrl: LADDER, streamUrl: REMUX });
|
|
209
|
+
await settle();
|
|
210
|
+
expect(configSeen?.maxBufferLength).toBe(HLS_CONFIG.maxBufferLength);
|
|
211
|
+
detach();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { attachHlsSource, canPlayHlsNatively } from './hlsSource';
|
|
3
3
|
|
|
4
|
+
// 🔴 The RECOVERY specs are a separate file, and the reason is a Bun behaviour rather than a
|
|
5
|
+
// taste: `mock.module('hls.js', …)` is applied at FILE scope no matter where it is called, so
|
|
6
|
+
// a fake engine registered inside one test is the engine every other test in the file gets.
|
|
7
|
+
// Measured here — "a ladder is preferred over the remux only where it can actually play"
|
|
8
|
+
// passed alone and failed beside the mocking tests, because the fake reports `isSupported()`.
|
|
9
|
+
// This file therefore proves the branches that need hls.js to be genuinely ABSENT;
|
|
10
|
+
// `hlsSource.recovery.spec.ts` proves the ones that need it present.
|
|
11
|
+
|
|
4
12
|
/** A `<video>` with a browser's answer to "can you play HLS?" stubbed onto it. */
|
|
5
13
|
function videoEl(canPlayHls: boolean): HTMLVideoElement {
|
|
6
14
|
const el = document.createElement('video');
|
|
@@ -30,9 +30,54 @@
|
|
|
30
30
|
* ⇧→ mounts one per clip, and thirty clips is thirty live engines still fetching segments for
|
|
31
31
|
* videos nobody is watching. The `destroyed` flag covers the same leak on the async path —
|
|
32
32
|
* an unmount that happens while the dynamic import is in flight.
|
|
33
|
+
*
|
|
34
|
+
* ── 🔴 An hls.js with no ERROR handler stops for good, and that was this file ────────────
|
|
35
|
+
* Owner report, 2026-09-17: *"sometimes a video is playing and starts buffering and ends up
|
|
36
|
+
* pausing instead of trying to wait it out."* That is not a description of a slow network —
|
|
37
|
+
* it is the documented behaviour of an unattended hls.js. The library retries a failing
|
|
38
|
+
* fragment on its own (six times, backing off to ~64 s in total) and then emits a FATAL error
|
|
39
|
+
* and **stops the loop**. Recovery is the application's job and always has been; this module
|
|
40
|
+
* was constructing the engine with `new Hls({ enableWorker: true })` and listening to
|
|
41
|
+
* nothing, so every fatal error was permanent.
|
|
42
|
+
*
|
|
43
|
+
* Three of them are recoverable and are handled here rather than surfaced:
|
|
44
|
+
*
|
|
45
|
+
* | fatal type | what it actually is here | what we do |
|
|
46
|
+
* |---|---|---|
|
|
47
|
+
* | `networkError` on a fragment | the tunnel blinked, or the segment was slow off a cold cache | `startLoad()` from where playback is, with a widening backoff |
|
|
48
|
+
* | `networkError` on the manifest | the ladder token expired mid-film, or bs is down | reload once, then fall back |
|
|
49
|
+
* | `mediaError` | a buffer-append or decode stall | `recoverMediaError()`, then the audio-codec-swap variant, then fall back |
|
|
50
|
+
*
|
|
51
|
+
* 🔴 And every road STILL ends at something playable: once the recoveries are spent, the
|
|
52
|
+
* engine is destroyed and the element is pointed at `streamUrl` **at the time it had reached**
|
|
53
|
+
* — a fallback that restarts a two-hour film from zero is a fallback the owner would rather
|
|
54
|
+
* not have had. That is the property `RECOVERY_BUDGET` bounds; without a budget a genuinely
|
|
55
|
+
* dead ladder becomes an infinite reload loop, which is the same spinner with more requests.
|
|
56
|
+
*
|
|
57
|
+
* ── 🔴 The config is tuned for a HIGH-LATENCY origin, which is what this fleet has ───────
|
|
58
|
+
* hls.js's defaults assume a CDN. These segments come off a home Mac through a tunnel when
|
|
59
|
+
* the edge does not hold them: measured 2026-09-17, a cold segment is 1,215–2,613 ms against
|
|
60
|
+
* ~150 ms warm, for four seconds of video. Two defaults are actively wrong at that latency —
|
|
61
|
+
* a 30 s forward buffer gives the player no room to absorb one slow fetch, and a 500 kbps
|
|
62
|
+
* starting bandwidth estimate makes it open on the bottom rung and climb. Both are widened
|
|
63
|
+
* below, each with its number's reason on the line.
|
|
33
64
|
*/
|
|
34
65
|
import { useEffect } from 'react';
|
|
35
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The shape of an hls.js `ERROR` payload this module reads.
|
|
69
|
+
*
|
|
70
|
+
* Declared locally rather than imported: `hls.js` is a DYNAMIC import in exactly one branch
|
|
71
|
+
* (~525 KB), and a type-only import from it at the top of the file is the kind of thing a
|
|
72
|
+
* bundler resolves into a static one. Only three fields are read, and all three have been
|
|
73
|
+
* stable across the library's 1.x line.
|
|
74
|
+
*/
|
|
75
|
+
interface HlsErrorData {
|
|
76
|
+
fatal?: boolean;
|
|
77
|
+
type?: string;
|
|
78
|
+
details?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
/** Which URLs a surface has for one video. `streamUrl` is the one that always exists. */
|
|
37
82
|
export interface HlsSourceUrls {
|
|
38
83
|
/** The adaptive ladder's MASTER playlist, when one has been built. */
|
|
@@ -58,9 +103,105 @@ export function canPlayHlsNatively(el: HTMLVideoElement): boolean {
|
|
|
58
103
|
* Synchronous for the native and progressive paths; the hls.js path resolves a dynamic
|
|
59
104
|
* import first, so the returned teardown has to be safe to call before that lands.
|
|
60
105
|
*/
|
|
106
|
+
/**
|
|
107
|
+
* How many times a fatal error may be recovered before the ladder is abandoned for the
|
|
108
|
+
* progressive copy.
|
|
109
|
+
*
|
|
110
|
+
* Four is two more than a transient blip needs and far fewer than a dead ladder would consume.
|
|
111
|
+
* The bound is the point: a `startLoad()` in an unbounded error handler is an infinite reload
|
|
112
|
+
* loop against a URL that is never going to answer — a spinner that also hammers the origin,
|
|
113
|
+
* which is strictly worse than the stall it replaced.
|
|
114
|
+
*/
|
|
115
|
+
export const RECOVERY_BUDGET = 4;
|
|
116
|
+
|
|
117
|
+
/** Backoff before each retry, in ms. Read by index, clamped to the last entry. */
|
|
118
|
+
const RECOVERY_BACKOFF_MS = [0, 1_000, 3_000, 8_000];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* hls.js settings for an origin that is a home Mac behind a tunnel, not a CDN.
|
|
122
|
+
*
|
|
123
|
+
* Exported so the specs can assert the two numbers that actually matter and so a surface with
|
|
124
|
+
* a genuinely different origin can spread this rather than re-derive it.
|
|
125
|
+
*/
|
|
126
|
+
export const HLS_CONFIG = {
|
|
127
|
+
enableWorker: true,
|
|
128
|
+
/**
|
|
129
|
+
* 🔴 90 s of forward buffer, against a default of 30.
|
|
130
|
+
*
|
|
131
|
+
* The player stalls when the buffer empties, and what empties it here is one slow fetch:
|
|
132
|
+
* a cold segment is up to 2.6 s for 4 s of video, so 30 s of buffer survives about a dozen
|
|
133
|
+
* of them back to back. Ninety gives the fetch loop room to fall behind and catch up
|
|
134
|
+
* without the viewer seeing it — which is the whole of *"buffers, plays a few seconds,
|
|
135
|
+
* buffers"*. It is a CEILING, not an allocation: a warm ladder fills it in seconds and a
|
|
136
|
+
* short clip never reaches it.
|
|
137
|
+
*/
|
|
138
|
+
maxBufferLength: 90,
|
|
139
|
+
/** The hard ceiling hls.js will stretch to when bandwidth is plentiful. */
|
|
140
|
+
maxMaxBufferLength: 600,
|
|
141
|
+
/** Bytes, not seconds, and the real limit on a 1080p rung. 120 MB ≈ 90 s at 5 Mbit. */
|
|
142
|
+
maxBufferSize: 120 * 1000 * 1000,
|
|
143
|
+
/**
|
|
144
|
+
* 🔴 Keep 30 s behind the playhead instead of hls.js's default of everything.
|
|
145
|
+
*
|
|
146
|
+
* Stepping back a few seconds is the commonest thing the owner does with a video and it
|
|
147
|
+
* should never re-fetch. Unbounded back-buffer is the other failure though: a two-hour film
|
|
148
|
+
* accumulates until the browser evicts the whole MediaSource, which presents as the video
|
|
149
|
+
* stopping for no reason.
|
|
150
|
+
*/
|
|
151
|
+
backBufferLength: 30,
|
|
152
|
+
/**
|
|
153
|
+
* 🔴 Start at 5 Mbit rather than hls.js's 500 kbit.
|
|
154
|
+
*
|
|
155
|
+
* The default assumes an unknown internet connection and opens on the bottom rung while it
|
|
156
|
+
* measures. Every viewer here is on the household's own network or a phone on it, the top
|
|
157
|
+
* rung is capped at 5,000 kbit by the encoder ladder, and starting low means the first
|
|
158
|
+
* thing anyone sees is 480p that visibly sharpens. One wrong guess costs one segment.
|
|
159
|
+
*/
|
|
160
|
+
abrEwmaDefaultEstimate: 5_000_000,
|
|
161
|
+
/** A cold segment off the tunnel can take seconds; 20 s (the default) times out a fetch
|
|
162
|
+
* that was going to succeed, and a timeout counts against the retry budget. */
|
|
163
|
+
fragLoadingTimeOut: 60_000,
|
|
164
|
+
manifestLoadingTimeOut: 30_000,
|
|
165
|
+
levelLoadingTimeOut: 30_000,
|
|
166
|
+
} as const;
|
|
167
|
+
|
|
61
168
|
export function attachHlsSource(el: HTMLVideoElement, urls: HlsSourceUrls): () => void {
|
|
62
169
|
let destroyed = false;
|
|
63
170
|
let engine: { destroy: () => void } | null = null;
|
|
171
|
+
let pendingRetry: ReturnType<typeof setTimeout> | null = null;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Abandon the ladder for the progressive copy, AT THE POSITION PLAYBACK HAD REACHED.
|
|
175
|
+
*
|
|
176
|
+
* 🔴 The seek is not a nicety. Without it, a fallback forty minutes into a film restarts it
|
|
177
|
+
* from zero — and because a fallback happens exactly when things are already going badly,
|
|
178
|
+
* that is the moment the owner is least likely to forgive it. `currentTime` is read before
|
|
179
|
+
* the engine is destroyed, because destroying it detaches the MediaSource and resets it.
|
|
180
|
+
*/
|
|
181
|
+
const fallBackToProgressive = (): void => {
|
|
182
|
+
if (destroyed) return;
|
|
183
|
+
const at = Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
|
184
|
+
const wasPlaying = !el.paused;
|
|
185
|
+
engine?.destroy();
|
|
186
|
+
engine = null;
|
|
187
|
+
el.src = urls.streamUrl;
|
|
188
|
+
if (at > 0) {
|
|
189
|
+
// `loadedmetadata` is the first moment a seek on a fresh source is honoured; setting
|
|
190
|
+
// `currentTime` before it is silently dropped.
|
|
191
|
+
el.addEventListener(
|
|
192
|
+
'loadedmetadata',
|
|
193
|
+
() => {
|
|
194
|
+
try {
|
|
195
|
+
el.currentTime = at;
|
|
196
|
+
} catch {
|
|
197
|
+
/* a source that refuses the seek still plays, from the start */
|
|
198
|
+
}
|
|
199
|
+
if (wasPlaying) void el.play?.().catch(() => {});
|
|
200
|
+
},
|
|
201
|
+
{ once: true },
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
64
205
|
|
|
65
206
|
if (urls.hlsUrl && canPlayHlsNatively(el)) {
|
|
66
207
|
el.src = urls.hlsUrl;
|
|
@@ -74,8 +215,53 @@ export function attachHlsSource(el: HTMLVideoElement, urls: HlsSourceUrls): () =
|
|
|
74
215
|
el.src = urls.streamUrl;
|
|
75
216
|
return;
|
|
76
217
|
}
|
|
77
|
-
const hls = new Hls(
|
|
218
|
+
const hls = new Hls(HLS_CONFIG);
|
|
78
219
|
engine = hls;
|
|
220
|
+
let spent = 0;
|
|
221
|
+
hls.on(Hls.Events.ERROR, (_event: unknown, data: HlsErrorData) => {
|
|
222
|
+
// Non-fatal errors are hls.js's own retry machinery working. Reacting to them would
|
|
223
|
+
// restart a load that is already in progress.
|
|
224
|
+
if (destroyed || !data?.fatal) return;
|
|
225
|
+
if (spent >= RECOVERY_BUDGET) {
|
|
226
|
+
fallBackToProgressive();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const attempt = spent++;
|
|
230
|
+
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
231
|
+
// 🔴 The SECOND media recovery must be the codec-swap variant, per hls.js's own
|
|
232
|
+
// guidance: calling `recoverMediaError()` twice for the same stall recovers
|
|
233
|
+
// nothing, and the audio-codec swap is what clears the case that keeps recurring.
|
|
234
|
+
if (attempt === 0) hls.recoverMediaError();
|
|
235
|
+
else hls.swapAudioCodec?.(), hls.recoverMediaError();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
239
|
+
const delay = RECOVERY_BACKOFF_MS[Math.min(attempt, RECOVERY_BACKOFF_MS.length - 1)] ?? 0;
|
|
240
|
+
// 🔴 Backed off, because the commonest cause is an origin that is briefly
|
|
241
|
+
// unreachable — a tunnel reconnecting, this Mac waking. Retrying instantly turns
|
|
242
|
+
// one blip into four and spends the whole budget inside a second.
|
|
243
|
+
//
|
|
244
|
+
// 🔴 And the previous timer is cleared first. Fatal errors arrive in bursts, so
|
|
245
|
+
// without this each one leaks a timer that `detach()` cannot reach — only the
|
|
246
|
+
// last handle was ever held — and a burst of four leaves three of them to fire
|
|
247
|
+
// into an engine React has already unmounted.
|
|
248
|
+
if (pendingRetry) clearTimeout(pendingRetry);
|
|
249
|
+
pendingRetry = setTimeout(() => {
|
|
250
|
+
pendingRetry = null;
|
|
251
|
+
if (destroyed) return;
|
|
252
|
+
try {
|
|
253
|
+
hls.startLoad();
|
|
254
|
+
} catch {
|
|
255
|
+
fallBackToProgressive();
|
|
256
|
+
}
|
|
257
|
+
}, delay);
|
|
258
|
+
pendingRetry.unref?.();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Anything else fatal (a mux error, an unknown type) is not something a reload
|
|
262
|
+
// fixes. The remux is the answer and it is the answer immediately.
|
|
263
|
+
fallBackToProgressive();
|
|
264
|
+
});
|
|
79
265
|
hls.loadSource(urls.hlsUrl as string);
|
|
80
266
|
hls.attachMedia(el);
|
|
81
267
|
})
|
|
@@ -89,6 +275,8 @@ export function attachHlsSource(el: HTMLVideoElement, urls: HlsSourceUrls): () =
|
|
|
89
275
|
|
|
90
276
|
return () => {
|
|
91
277
|
destroyed = true;
|
|
278
|
+
if (pendingRetry) clearTimeout(pendingRetry);
|
|
279
|
+
pendingRetry = null;
|
|
92
280
|
engine?.destroy();
|
|
93
281
|
engine = null;
|
|
94
282
|
};
|