cursedbelt 5.97.0 → 5.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  2. package/dist/react/media/FocusViewer.js +3 -2
  3. package/dist/react/media/FocusViewer.js.map +1 -1
  4. package/dist/react/media/ImageOriginalToggle.d.ts +22 -0
  5. package/dist/react/media/ImageOriginalToggle.d.ts.map +1 -0
  6. package/dist/react/media/ImageOriginalToggle.js +16 -0
  7. package/dist/react/media/ImageOriginalToggle.js.map +1 -0
  8. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  9. package/dist/react/media/VideoPlayer.js +9 -3
  10. package/dist/react/media/VideoPlayer.js.map +1 -1
  11. package/dist/react/media/VideoQualitySelect.d.ts +24 -0
  12. package/dist/react/media/VideoQualitySelect.d.ts.map +1 -0
  13. package/dist/react/media/VideoQualitySelect.js +34 -0
  14. package/dist/react/media/VideoQualitySelect.js.map +1 -0
  15. package/dist/react/media/hlsSource.d.ts +14 -2
  16. package/dist/react/media/hlsSource.d.ts.map +1 -1
  17. package/dist/react/media/hlsSource.js +27 -6
  18. package/dist/react/media/hlsSource.js.map +1 -1
  19. package/dist/react/media/imageOriginal.d.ts +69 -0
  20. package/dist/react/media/imageOriginal.d.ts.map +1 -0
  21. package/dist/react/media/imageOriginal.js +98 -0
  22. package/dist/react/media/imageOriginal.js.map +1 -0
  23. package/dist/react/media/imageOriginalEntry.d.ts +9 -0
  24. package/dist/react/media/imageOriginalEntry.d.ts.map +1 -0
  25. package/dist/react/media/imageOriginalEntry.js +9 -0
  26. package/dist/react/media/imageOriginalEntry.js.map +1 -0
  27. package/dist/react/media/index.d.ts +5 -0
  28. package/dist/react/media/index.d.ts.map +1 -1
  29. package/dist/react/media/index.js +6 -0
  30. package/dist/react/media/index.js.map +1 -1
  31. package/dist/react/media/useImageOriginal.d.ts +21 -0
  32. package/dist/react/media/useImageOriginal.d.ts.map +1 -0
  33. package/dist/react/media/useImageOriginal.js +63 -0
  34. package/dist/react/media/useImageOriginal.js.map +1 -0
  35. package/dist/react/media/videoPlayer.css +23 -0
  36. package/dist/react/media/videoQuality.d.ts +113 -0
  37. package/dist/react/media/videoQuality.d.ts.map +1 -0
  38. package/dist/react/media/videoQuality.js +185 -0
  39. package/dist/react/media/videoQuality.js.map +1 -0
  40. package/dist/react/media/vidstackQuality.d.ts +27 -0
  41. package/dist/react/media/vidstackQuality.d.ts.map +1 -0
  42. package/dist/react/media/vidstackQuality.js +51 -0
  43. package/dist/react/media/vidstackQuality.js.map +1 -0
  44. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  45. package/dist/react/media-gallery/MediaGallery.js +42 -10
  46. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  47. package/dist/react/media-gallery/mediaGallery.css +42 -0
  48. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  49. package/dist/react/media-gallery/mediaGalleryModel.js +5 -2
  50. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  51. package/dist/react/media-gallery/types.d.ts +10 -0
  52. package/dist/react/media-gallery/types.d.ts.map +1 -1
  53. package/dist/react/sharing/SharePanel.d.ts.map +1 -1
  54. package/dist/react/sharing/SharePanel.js +2 -1
  55. package/dist/react/sharing/SharePanel.js.map +1 -1
  56. package/package.json +8 -2
  57. package/scripts/paths.ts +1 -1
  58. package/scripts/styleAreas.ts +2 -1
  59. package/src/react/media/FocusViewer.tsx +9 -1
  60. package/src/react/media/ImageOriginalToggle.tsx +51 -0
  61. package/src/react/media/VideoPlayer.spec.tsx +44 -1
  62. package/src/react/media/VideoPlayer.tsx +17 -3
  63. package/src/react/media/VideoQualitySelect.spec.tsx +47 -0
  64. package/src/react/media/VideoQualitySelect.tsx +81 -0
  65. package/src/react/media/hlsSource.spec.ts +10 -0
  66. package/src/react/media/hlsSource.ts +41 -7
  67. package/src/react/media/imageOriginal.spec.tsx +183 -0
  68. package/src/react/media/imageOriginal.ts +114 -0
  69. package/src/react/media/imageOriginalEntry.ts +8 -0
  70. package/src/react/media/index.ts +9 -0
  71. package/src/react/media/useImageOriginal.ts +97 -0
  72. package/src/react/media/videoPlayer.css +23 -0
  73. package/src/react/media/videoQuality.spec.ts +135 -0
  74. package/src/react/media/videoQuality.ts +248 -0
  75. package/src/react/media/vidstackQuality.tsx +65 -0
  76. package/src/react/media-gallery/MediaGallery.spec.tsx +26 -0
  77. package/src/react/media-gallery/MediaGallery.tsx +57 -17
  78. package/src/react/media-gallery/mediaGallery.css +42 -0
  79. package/src/react/media-gallery/mediaGalleryModel.ts +4 -2
  80. package/src/react/media-gallery/types.ts +10 -0
  81. package/src/react/sharing/SharePanel.tsx +2 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The quality menu on cursedbelt's Vidstack player (forge task 3868) — kept out of
3
+ * `VideoPlayer.tsx` so the `./react/media/video-player` subpath exports what it always did.
4
+ */
5
+ import { isHLSProvider, type MediaProviderAdapter } from '@vidstack/react';
6
+ import type { DefaultVideoLayoutSlots } from '@vidstack/react/player/layouts/default';
7
+ import { useSyncExternalStore } from 'react';
8
+ import {
9
+ bindHlsQuality,
10
+ type HlsQualityEngine,
11
+ NATIVE_HLS_REASON,
12
+ NO_LADDER_REASON,
13
+ readVideoQuality,
14
+ unavailableQuality,
15
+ type VideoQualityStore,
16
+ } from './videoQuality.js';
17
+ import { VideoQualitySelect } from './VideoQualitySelect.js';
18
+
19
+ /**
20
+ * The viewer's quality choice on Vidstack's hls.js (forge task 3868) — the same choice, read from
21
+ * the same key, as every `hlsSource.ts` surface (`videoQuality.ts` has what each one means).
22
+ *
23
+ * 🔴 Auto is capped at the player's size HERE TOO. Vidstack's hls.js ran with the library's
24
+ * defaults, so this stage alone fetched the top rung into any size of player; with that rung now
25
+ * the ORIGINAL (a 4K film's is ~20 Mbps), Auto means the same thing on every surface, and Max is
26
+ * the one remembered choice that lifts the cap.
27
+ *
28
+ * Safari's own HLS (Vidstack's plain `VideoProvider` playing a ladder) and the progressive copy
29
+ * can choose nothing, and say why.
30
+ */
31
+ export function bindVidstackQuality(
32
+ provider: MediaProviderAdapter | null,
33
+ store: VideoQualityStore,
34
+ ladder: boolean,
35
+ ): void {
36
+ if (!provider) {
37
+ store.set(null);
38
+ return;
39
+ }
40
+ if (isHLSProvider(provider)) {
41
+ store.set(null);
42
+ provider.config = { ...provider.config, capLevelToPlayerSize: readVideoQuality() === 'auto' };
43
+ provider.onInstance((hls) => {
44
+ bindHlsQuality(hls as unknown as HlsQualityEngine, store.set);
45
+ });
46
+ return;
47
+ }
48
+ store.set(unavailableQuality(ladder ? NATIVE_HLS_REASON : NO_LADDER_REASON));
49
+ }
50
+
51
+ /** The quality menu in the bar, re-rendered by the store alone — see `VideoQualityStore`. */
52
+ function QualitySlot({ store }: { store: VideoQualityStore }) {
53
+ const control = useSyncExternalStore(store.subscribe, store.get, store.get);
54
+ return <VideoQualitySelect className='cbvp-quality' control={control} fieldStyle={false} />;
55
+ }
56
+
57
+ /**
58
+ * Quality beside the settings cog, where YouTube keeps it. It REPLACES Vidstack's own quality
59
+ * submenu (hidden in `videoPlayer.css`): that one has no Max, remembers nothing, and two menus
60
+ * choosing one thing would disagree.
61
+ */
62
+ export function qualitySlots(store: VideoQualityStore): DefaultVideoLayoutSlots {
63
+ const pair = { beforeSettingsMenu: <QualitySlot store={store} /> };
64
+ return { ...pair, smallLayout: { ...pair }, largeLayout: { ...pair } };
65
+ }
@@ -364,6 +364,32 @@ describe('MediaGallery detail view', () => {
364
364
  expect(container.querySelector('.cbgd-root')).not.toBeNull();
365
365
  });
366
366
 
367
+ test('🔴 a photo with an original draws the web copy and an Original toggle; one without draws none (forge task 3873)', () => {
368
+ const withOriginal: MediaGalleryItem[] = [
369
+ { ...(ITEMS[0] as MediaGalleryItem), url: '/files/img1.web.jpg', originalUrl: '/files/img1' },
370
+ ];
371
+ const first = render(<MediaGallery items={withOriginal} />);
372
+ fireEvent.click(first.getByRole('button', { name: 'Open beach.jpg' }));
373
+ expect(first.container.querySelector('.cbgd-stage-img img')?.getAttribute('src')).toBe('/files/img1.web.jpg');
374
+ const toggle = first.getByRole('button', { name: 'Original' });
375
+ expect(toggle.getAttribute('aria-pressed')).toBe('false');
376
+ expect(toggle.getAttribute('aria-disabled')).not.toBe('true');
377
+ cleanup();
378
+
379
+ const heic: MediaGalleryItem[] = [
380
+ { ...(ITEMS[0] as MediaGalleryItem), name: 'IMG_1.HEIC', mime: 'image/heic', url: '/files/img1.web.jpg', originalUrl: '/files/img1' },
381
+ ];
382
+ const second = render(<MediaGallery items={heic} />);
383
+ fireEvent.click(second.getByRole('button', { name: 'Open IMG_1.HEIC' }));
384
+ // Refused, never hidden: the HEIC original is exactly what the web copy exists for.
385
+ expect(second.getByRole('button', { name: /Original/ }).getAttribute('aria-disabled')).toBe('true');
386
+ cleanup();
387
+
388
+ const plain = render(<MediaGallery items={ITEMS} />);
389
+ fireEvent.click(plain.getByRole('button', { name: 'Open beach.jpg' }));
390
+ expect(plain.queryByRole('button', { name: 'Original' })).toBeNull();
391
+ });
392
+
367
393
  test('onDelete renders a confirm-gated delete and closes the detail after it runs', async () => {
368
394
  let deleted: string | null = null;
369
395
  const { getByRole, findByRole, container } = render(
@@ -80,6 +80,9 @@ import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShel
80
80
  import { CBGD_BARE_TILE_HEIGHT } from '../media/VirtualMediaGrid.js';
81
81
  import { useGalleryReturn } from './galleryReturn.js';
82
82
  import { useHlsSource } from '../media/hlsSource.js';
83
+ import { VideoQualitySelect } from '../media/VideoQualitySelect.js';
84
+ import { ImageOriginalToggle } from '../media/ImageOriginalToggle.js';
85
+ import { useImageOriginal } from '../media/useImageOriginal.js';
83
86
  import {
84
87
  isRecoverableFault,
85
88
  probeSourceFault,
@@ -1197,22 +1200,27 @@ function NativeVideoStage({
1197
1200
  onFault?: (fault: SourceFault) => void;
1198
1201
  }) {
1199
1202
  const ref = React.useRef<HTMLVideoElement>(null);
1200
- useHlsSource(ref, { hlsUrl, streamUrl }, { ...(onFault ? { onFault } : {}) });
1203
+ const quality = useHlsSource(ref, { hlsUrl, streamUrl }, { ...(onFault ? { onFault } : {}) });
1201
1204
  useStageVolume(ref);
1202
1205
  return (
1203
- // biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions
1204
- <video
1205
- ref={ref}
1206
- className='cbmg-stage-video'
1207
- poster={poster}
1208
- controls
1209
- autoPlay
1210
- playsInline
1211
- loop={repeat}
1212
- // A looping element never fires `ended`, so passing both would silently disable
1213
- // auto-advance rather than combining the two. See `VideoPlayerProps.loop`.
1214
- onEnded={repeat ? undefined : onEnded}
1215
- />
1206
+ // The box exists for the quality menu (task 3868): native `controls` take no extra control,
1207
+ // so the menu sits in the picture's top corner and shows when the controls do.
1208
+ <div className='cbmg-stage-native'>
1209
+ {/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions */}
1210
+ <video
1211
+ ref={ref}
1212
+ className='cbmg-stage-video'
1213
+ poster={poster}
1214
+ controls
1215
+ autoPlay
1216
+ playsInline
1217
+ loop={repeat}
1218
+ // A looping element never fires `ended`, so passing both would silently disable
1219
+ // auto-advance rather than combining the two. See `VideoPlayerProps.loop`.
1220
+ onEnded={repeat ? undefined : onEnded}
1221
+ />
1222
+ <VideoQualitySelect className='cbmg-stage-quality' control={quality} />
1223
+ </div>
1216
1224
  );
1217
1225
  }
1218
1226
 
@@ -1337,13 +1345,36 @@ function NativeVideoSurface({
1337
1345
  function ImageSurface({
1338
1346
  item,
1339
1347
  url,
1348
+ originalUrl,
1340
1349
  reAsk,
1341
1350
  }: {
1342
1351
  item: MediaGalleryItem;
1343
1352
  url: string;
1353
+ originalUrl?: string | undefined;
1344
1354
  reAsk: () => boolean;
1345
1355
  }) {
1346
1356
  const { fault, report } = useSourceFault(reAsk);
1357
+ // How wide the picture is DRAWN, and how wide the web copy is — the two numbers that decide
1358
+ // whether the original is needed (`useImageOriginal`). The `<img>` is `contain`ed with max
1359
+ // sizes, so its own box IS the drawn picture.
1360
+ const [drawnWidth, setDrawnWidth] = useState(0);
1361
+ const [webWidth, setWebWidth] = useState(0);
1362
+ const observer = useRef<ResizeObserver | null>(null);
1363
+ const measure = useCallback((img: HTMLImageElement | null) => {
1364
+ observer.current?.disconnect();
1365
+ observer.current = null;
1366
+ if (!img || typeof ResizeObserver === 'undefined') return;
1367
+ observer.current = new ResizeObserver(() => setDrawnWidth(img.clientWidth));
1368
+ observer.current.observe(img);
1369
+ }, []);
1370
+ const original = useImageOriginal({
1371
+ src: url,
1372
+ originalSrc: originalUrl,
1373
+ mime: item.mime,
1374
+ name: item.name,
1375
+ drawnWidth,
1376
+ webWidth,
1377
+ });
1347
1378
  if (fault) {
1348
1379
  return <StageSourceFault item={item} category='image' fault={fault} subject='This picture' />;
1349
1380
  }
@@ -1354,13 +1385,22 @@ function ImageSurface({
1354
1385
  constraint: a quarter-turned picture is laid out in its ORIGINAL box, so without it
1355
1386
  a portrait scan turned upright overflows the frame it is centered in. */}
1356
1387
  <img
1357
- src={url}
1388
+ ref={measure}
1389
+ src={original.src}
1358
1390
  alt={item.name}
1359
1391
  style={orientationStyle(item)}
1392
+ onLoad={(event) => {
1393
+ // Only the WEB copy's size is the threshold — the original's is what it is past.
1394
+ if (original.src === url) setWebWidth(event.currentTarget.naturalWidth);
1395
+ setDrawnWidth(event.currentTarget.clientWidth);
1396
+ }}
1360
1397
  onError={() => {
1361
- void probeSourceFault(url).then(report);
1398
+ // The original failing is `useImageOriginal`'s own case (it decodes before the swap);
1399
+ // a fault here is the web copy's.
1400
+ void probeSourceFault(original.src).then(report);
1362
1401
  }}
1363
1402
  />
1403
+ <ImageOriginalToggle className='cbgd-stage-original' control={original.control} />
1364
1404
  </div>
1365
1405
  );
1366
1406
  }
@@ -1484,7 +1524,7 @@ function GalleryStage({
1484
1524
  if (!media.url) return <StageLoading label={resolving ? 'Loading…' : 'Image unavailable.'} />;
1485
1525
  // `key`ed on the URL for the reason the video stage is: a new URL — including the one a
1486
1526
  // re-ask mints — is a new attempt and must not inherit the previous one's fault.
1487
- return <ImageSurface key={media.url} item={item} url={media.url} reAsk={refresh} />;
1527
+ return <ImageSurface key={media.url} item={item} url={media.url} originalUrl={media.originalUrl} reAsk={refresh} />;
1488
1528
  }
1489
1529
 
1490
1530
  if (category === 'audio') {
@@ -135,6 +135,35 @@
135
135
  max-height: 100%;
136
136
  height: 100%;
137
137
  }
138
+ /* The native stage's box: exactly the video's, so the quality menu (task 3868) can sit in its
139
+ corner. It shows with the native controls — on hover or focus — and always on touch, where
140
+ there is no hover to reveal it. */
141
+ .cbmg-stage-native {
142
+ position: relative;
143
+ width: 100%;
144
+ height: 100%;
145
+ }
146
+ .cbmg-stage-quality {
147
+ position: absolute;
148
+ top: 8px;
149
+ right: 8px;
150
+ opacity: 0;
151
+ transition: opacity 150ms ease;
152
+ }
153
+ .cbmg-stage-native:hover .cbmg-stage-quality,
154
+ .cbmg-stage-quality:focus-within {
155
+ opacity: 1;
156
+ }
157
+ @media (hover: none) {
158
+ .cbmg-stage-quality {
159
+ opacity: 1;
160
+ }
161
+ }
162
+ @media (pointer: coarse) {
163
+ .cbmg-stage-quality button {
164
+ min-height: 44px;
165
+ }
166
+ }
138
167
 
139
168
  /* ── A TURNED clip (`MediaGalleryItem.orientation` on a video). The owner, 2026-08-14:
140
169
  *"a video showing in portrait mode that is actually supposed to be widescreen to fill all
@@ -227,6 +256,7 @@
227
256
 
228
257
  /* Inside the turned frame the stage element must stop claiming its own 70vh, or the flex
229
258
  centering above has nothing left to center. */
259
+ .cbmg-stage-turn[data-turned='true'] .cbmg-stage-native,
230
260
  .cbmg-stage-turn[data-turned='true'] .cbmg-stage-video,
231
261
  .cbmg-stage-turn[data-turned='true'] [data-testid='video-player'] {
232
262
  height: auto;
@@ -1067,3 +1097,15 @@
1067
1097
  font-size: 13px;
1068
1098
  color: var(--muted-foreground);
1069
1099
  }
1100
+
1101
+ /* The "Original" toggle on a picture's stage (forge task 3873, `ImageOriginalToggle`): over the
1102
+ letterbox's bottom-right corner, where a viewer's eye is not, and above the picture it swaps.
1103
+ `.cbgd-stage-img` is a flex box, so it anchors the absolute corner. */
1104
+ .cbgd-stage-img {
1105
+ position: relative;
1106
+ }
1107
+ .cbgd-stage-original {
1108
+ position: absolute;
1109
+ right: 12px;
1110
+ bottom: 12px;
1111
+ }
@@ -93,8 +93,8 @@ export function galleryDateHaystack(createdAt: number | undefined): string {
93
93
  const date = new Date(createdAt);
94
94
  if (Number.isNaN(date.getTime())) return '';
95
95
  const pad = (n: number) => String(n).padStart(2, '0');
96
- const iso = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
97
- const parts = [iso, iso.slice(0, 7), iso.slice(0, 4), date.toLocaleDateString()];
96
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
97
+ const parts = [day, day.slice(0, 7), day.slice(0, 4), date.toLocaleDateString()];
98
98
  for (const month of ['short', 'long'] as const) {
99
99
  parts.push(date.toLocaleDateString(undefined, { day: 'numeric', month, year: 'numeric' }));
100
100
  parts.push(date.toLocaleDateString(undefined, { month, year: 'numeric' }));
@@ -205,6 +205,8 @@ export function staticItemMedia(item: MediaGalleryItem): MediaGalleryItemMedia {
205
205
  const media: MediaGalleryItemMedia = {};
206
206
  if (item.url) media.url = item.url;
207
207
  if (item.thumbnailUrl) media.thumbnailUrl = item.thumbnailUrl;
208
+ // A still's original past its web copy — see `MediaGalleryItemMedia.originalUrl`.
209
+ if (item.originalUrl) media.originalUrl = item.originalUrl;
208
210
  if (item.posterUrl) media.posterUrl = item.posterUrl;
209
211
  if (item.streamUrl) media.streamUrl = item.streamUrl;
210
212
  // Additional to `streamUrl`, never instead of it — see `MediaGalleryItemMedia.hlsUrl`.
@@ -33,6 +33,16 @@ export interface MediaGalleryItemMedia {
33
33
  url?: string;
34
34
  /** Smaller grid derivative; tiles fall back to `url` when absent. */
35
35
  thumbnailUrl?: string;
36
+ /**
37
+ * A still's ORIGINAL, signed inline, when `url` is its web copy (forge task 3873).
38
+ *
39
+ * The stage draws `url` and swaps in this one once the picture is drawn with more device
40
+ * pixels than the web copy holds, or when the viewer pressed "Original" — and only after it
41
+ * has decoded. Absent (an app whose `url` already IS the original) draws no control. An
42
+ * original no browser can draw (a HEIC, a RAW) is still worth handing over: the control
43
+ * then says why it is refused. See `imageOriginal.ts`.
44
+ */
45
+ originalUrl?: string;
36
46
  /** Video poster frame for the idle tile. */
37
47
  posterUrl?: string;
38
48
  /** Range-streamable video source when it differs from `url` (signed stream endpoints). */
@@ -27,6 +27,7 @@
27
27
  * no controls rather than nothing at all: a relative who cannot change sharing
28
28
  * still benefits from knowing the record is shared with their cousin.
29
29
  */
30
+ import { dayKeyOf } from "cwip/calendar";
30
31
  import { useState } from "react";
31
32
  import {
32
33
  describeAccess,
@@ -243,7 +244,7 @@ export function SharePanel({
243
244
  <ol className="mt-1 flex flex-col gap-1 text-[var(--muted-foreground)] text-sm">
244
245
  {events.map((event) => (
245
246
  <li key={event.id}>
246
- <span className="tabular-nums">{event.at.slice(0, 10)}</span>{" "}
247
+ <span className="tabular-nums">{dayKeyOf(Date.parse(event.at))}</span>{" "}
247
248
  {describeShareEvent(event, labelFor(event.subject))}
248
249
  </li>
249
250
  ))}