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