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.
Files changed (250) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +111 -31
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/styleAreas.ts +585 -0
  151. package/scripts/verify.ts +5 -0
  152. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  153. package/src/docsMatchTheSplit.spec.ts +98 -0
  154. package/src/fixtureAppCss.spec.ts +177 -0
  155. package/src/namedSubpathsResolve.spec.ts +35 -0
  156. package/src/publishShape.spec.ts +67 -0
  157. package/src/react/components/ListToolbar.spec.tsx +172 -0
  158. package/src/react/components/ListToolbar.tsx +151 -0
  159. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  160. package/src/react/file-tree/FileTree.tsx +1459 -309
  161. package/src/react/file-tree/fileTree.css +151 -0
  162. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  163. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  164. package/src/react/file-tree/index.ts +21 -0
  165. package/src/react/index.ts +1 -0
  166. package/src/react/lib/form.ts +51 -8
  167. package/src/react/media/DenseGalleryShell.tsx +108 -36
  168. package/src/react/media/FocusViewer.tsx +15 -8
  169. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  170. package/src/react/media/FrameGrabber.tsx +67 -0
  171. package/src/react/media/MediaResize.spec.tsx +84 -14
  172. package/src/react/media/MediaResize.tsx +51 -19
  173. package/src/react/media/VideoChapterEditor.tsx +428 -0
  174. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  175. package/src/react/media/VideoPlayer.tsx +158 -11
  176. package/src/react/media/VideoTurner.spec.tsx +67 -0
  177. package/src/react/media/VideoTurner.tsx +70 -15
  178. package/src/react/media/denseGallery.css +104 -46
  179. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  180. package/src/react/media/hlsSource.spec.ts +8 -0
  181. package/src/react/media/hlsSource.ts +189 -1
  182. package/src/react/media/index.ts +14 -0
  183. package/src/react/media/mediaResize.css +127 -54
  184. package/src/react/media/pictureEditor.css +52 -6
  185. package/src/react/media/videoChapterEditor.css +203 -0
  186. package/src/react/media/videoChapters.spec.ts +176 -0
  187. package/src/react/media/videoChapters.ts +181 -0
  188. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  189. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  190. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  191. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  192. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  193. package/src/react/media-gallery/index.ts +7 -0
  194. package/src/react/media-gallery/mediaGallery.css +180 -4
  195. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  196. package/src/react/media-gallery/mediaMeta.ts +101 -0
  197. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  198. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  199. package/src/react/media-gallery/types.ts +34 -0
  200. package/src/shippedFilesAreTracked.spec.ts +69 -0
  201. package/src/styles-areas/activity-bar.css +30 -0
  202. package/src/styles-areas/analytics.css +672 -0
  203. package/src/styles-areas/auth.css +154 -0
  204. package/src/styles-areas/calendar.css +416 -0
  205. package/src/styles-areas/chart-table.css +41 -0
  206. package/src/styles-areas/charts.css +89 -0
  207. package/src/styles-areas/chat.css +364 -0
  208. package/src/styles-areas/clipboard.css +26 -0
  209. package/src/styles-areas/code-editor.css +130 -0
  210. package/src/styles-areas/companion-link.css +70 -0
  211. package/src/styles-areas/core.css +2503 -0
  212. package/src/styles-areas/dashboard-grid.css +84 -0
  213. package/src/styles-areas/data-table.css +657 -0
  214. package/src/styles-areas/deep-link.css +26 -0
  215. package/src/styles-areas/diff-viewer.css +30 -0
  216. package/src/styles-areas/disk-usage.css +262 -0
  217. package/src/styles-areas/emoji.css +154 -0
  218. package/src/styles-areas/fields.css +582 -0
  219. package/src/styles-areas/file-tree.css +33 -0
  220. package/src/styles-areas/filter-rail.css +235 -0
  221. package/src/styles-areas/folder-tree.css +219 -0
  222. package/src/styles-areas/keep-awake.css +26 -0
  223. package/src/styles-areas/layout-engine.css +239 -0
  224. package/src/styles-areas/markdown.css +87 -0
  225. package/src/styles-areas/master-detail.css +239 -0
  226. package/src/styles-areas/master-lock.css +26 -0
  227. package/src/styles-areas/media-gallery.css +351 -0
  228. package/src/styles-areas/media.css +658 -0
  229. package/src/styles-areas/nav.css +488 -0
  230. package/src/styles-areas/notifications.css +245 -0
  231. package/src/styles-areas/overlays.css +133 -0
  232. package/src/styles-areas/palette.css +140 -0
  233. package/src/styles-areas/pdf-viewer.css +47 -0
  234. package/src/styles-areas/problem-detail.css +97 -0
  235. package/src/styles-areas/rich-text.css +167 -0
  236. package/src/styles-areas/sharing.css +153 -0
  237. package/src/styles-areas/spreadsheet.css +37 -0
  238. package/src/styles-areas/stats.css +281 -0
  239. package/src/styles-areas/test-report.css +133 -0
  240. package/src/styles-areas/virtual.css +30 -0
  241. package/src/styles-areas/wizard.css +381 -0
  242. package/src/styles-areas/workbench.css +543 -0
  243. package/src/styles-areas/workbook-viewer.css +44 -0
  244. package/src/styles-static.css +34 -23
  245. package/src/styles.css +34 -23
  246. package/src/stylesAreas.spec.ts +247 -0
  247. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  248. package/src/testFilesRunInParallel.spec.ts +79 -0
  249. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  250. 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
- /* The anchor for the chrome that rides ON the frame — today that is the resize grip
412
- (`.cbmr-corner`). It must be positioned against the FRAME, which is constant, and
413
- never against the picture, which is not.
414
- HISTORY: the big overlay ⟨ ⟩ step arrows used to hang here too. They were removed on
415
- 2026-08-25 — owner: *"Remove the big previous and next buttons off the sides of the
416
- video."* Stepping moved into `.cbgd-watch-step` in the titlebar, and for video into
417
- the player's own control bar. Do not put them back on the frame. */
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
- /* Every STAGE fills the frame — but the resize grip is chrome ON the frame, not a stage, and
423
- a `height: 100%` grip is a 44px control stretched over the whole picture, swallowing every
424
- click meant for the video under it. Excluded by name rather than left to stylesheet order,
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
- /* ── Where the video's own controls may NOT lay out ──────────────────────────
432
- * Owner, 2026-08-25: *"make the corner expansion grabbable area bigger … for some
433
- * reason it's hard to grab, possibly the previous/next buttons are overlapping it."*
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
- * 🔴 It was not the arrows. Measured against `@vidstack/react` 1.15's own theme:
436
- * `[data-view-type='video'] .vds-controls` is `position:absolute; inset:0;
437
- * z-index:10`, and `[data-media-player]` sets only `contain: style`, which is NOT one
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
- * Raising the grip alone would have covered the player's own last button (fullscreen)
447
- * with a 64px slider, so the fix is symmetrical: the grip goes above the bar (see
448
- * `mediaResize.css`) AND the bar is told to stop short of the corner it now shares a
449
- * frame with. `padding-inline-end` rather than a narrower width because Vidstack's
450
- * groups are `width:100%` flex rows — the padding is what actually moves the buttons.
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
- * 🔴 Scoped to a player that is NOT fullscreen. In fullscreen the media element is the
453
- * top-level box and the gallery's frame — grip included — is not in its tree at all,
454
- * so reserving a corner there would be a permanent empty gutter beside the cog with
455
- * nothing in it.
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 grips ──────────────────────────────────────────────────
677
- * They are `mediaResize.css`'s (`.cbmr-corner` on the frame, `.cbmr-bar` under it),
678
- * shared with the focus viewer since 2026-08-25 — the day a handle that existed only
679
- * here was reported as a feature that did not exist. Only the BAR's width is this
680
- * module's business, because only this module knows the frame is 16:9-capped. */
681
- .cbgd-watch-player > .cbmr-bar {
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({ enableWorker: true });
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
  };