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,114 @@
1
+ /**
2
+ * A picture's ORIGINAL, reached past its web copy — the image half of "Max" (forge task 3873).
3
+ *
4
+ * ── Why (forge task 3868, the owner's ruling of 2026-10-28) ─────────────────────────────
5
+ * binary-server keeps every image original now, but the web copy every viewer draws is a JPEG
6
+ * capped at 2560 px on its longest edge (`DEFAULT_IMAGE_SIZES`). A 48 MP photograph zoomed to
7
+ * read a face was still the 2560-px JPEG, and the original was reachable only as a download.
8
+ * The video player's answer was a menu (Auto · Max · each rung); a picture has two copies, so
9
+ * its answer is two choices:
10
+ *
11
+ * | choice | what the viewer draws |
12
+ * |---|---|
13
+ * | auto (the default) | the web copy, and the original the moment the picture is drawn with more device pixels than the web copy has — a zoom, or a 5K screen |
14
+ * | original | the original, always |
15
+ *
16
+ * ── 🔴 The web copy stays on screen until the original has DECODED ──────────────────────
17
+ * A 48 MP JPEG is 10–20 MB and a second or more to decode. Pointing the `<img>` at it at once
18
+ * blanks the picture, mid-zoom, for that long. `useImageOriginal` decodes it off-screen
19
+ * (`HTMLImageElement.decode()`) and swaps only then, so the swap is one frame.
20
+ *
21
+ * ── 🔴 Only a type the browser can draw ─────────────────────────────────────────────────
22
+ * A DNG, a HEIC or a TIFF original is exactly what the web copy exists FOR. The control stays
23
+ * visible and disabled with that reason (the fleet's one-UI rule), and the viewer keeps the
24
+ * JPEG.
25
+ *
26
+ * ── Remembered per VIEWER, in `localStorage` ────────────────────────────────────────────
27
+ * The same reasoning as `cb.video-quality` (`videoQuality.ts`): a phone on cellular and the
28
+ * desk's 5K screen want different answers, and every image viewer reads the one key.
29
+ */
30
+
31
+ /** The one key every image viewer reads and writes: `'original'`, or absent for auto. */
32
+ export const IMAGE_ORIGINAL_KEY = 'cb.image-quality';
33
+
34
+ /**
35
+ * Drawn with this many more device pixels than the web copy holds before the original is
36
+ * fetched. A hair over 1: a picture filling a 2560-px screen exactly gains nothing from 20 MB.
37
+ */
38
+ export const ORIGINAL_NEED_RATIO = 1.1;
39
+
40
+ export const ORIGINAL_FAILED_REASON = 'The original did not load — the web copy is showing';
41
+
42
+ /**
43
+ * Why no browser can draw this original, or null when one can.
44
+ *
45
+ * 🔴 The mime list mirrors cursedbelt-server's `isUnviewableStill`
46
+ * (`src/server/storage/derivatives.ts`) and binary-server's `isUnviewableOriginal` — the
47
+ * stills bs makes a web copy for no matter how small, because nothing else can show them —
48
+ * and adds the camera RAWs bs never draws either. The NAME is read too: a `.DNG` or a `.HEIC`
49
+ * is often cataloged as `application/octet-stream`, for the reason that file gives.
50
+ */
51
+ export function originalUndrawableReason(mime: string | null | undefined, name?: string | null): string | null {
52
+ const bare = (mime ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
53
+ const extension = (name ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '';
54
+ const kind = /^image\/(heic|heif)(-sequence)?$/.test(bare) || ['heic', 'heif'].includes(extension)
55
+ ? 'HEIC'
56
+ : bare === 'image/tiff' || ['tif', 'tiff'].includes(extension)
57
+ ? 'TIFF'
58
+ : bare === 'image/x-adobe-dng' || RAW_EXTENSIONS.includes(extension)
59
+ ? 'camera RAW'
60
+ : null;
61
+ return kind ? `The original is a ${kind}, which this browser cannot draw — Download has it` : null;
62
+ }
63
+
64
+ const RAW_EXTENSIONS = ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'orf', 'rw2', 'pef', 'srw'];
65
+
66
+ /**
67
+ * Does a picture drawn `drawnWidth` CSS pixels wide (zoom included) need more than a web copy
68
+ * `webWidth` pixels wide holds, on a screen of `devicePixelRatio`?
69
+ */
70
+ export function originalNeeded(drawnWidth: number, webWidth: number, devicePixelRatio = 1): boolean {
71
+ if (!(drawnWidth > 0) || !(webWidth > 0)) return false;
72
+ return drawnWidth * Math.max(1, devicePixelRatio) > webWidth * ORIGINAL_NEED_RATIO;
73
+ }
74
+
75
+ /** Guarded: storage throws in a private window, and is absent in a worker or on the server. */
76
+ function store(): Storage | null {
77
+ try {
78
+ return globalThis.localStorage ?? null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** True when this viewer chose "always the original". */
85
+ export function readImageOriginalPinned(): boolean {
86
+ try {
87
+ return store()?.getItem(IMAGE_ORIGINAL_KEY) === 'original';
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function writeImageOriginalPinned(pinned: boolean): void {
94
+ try {
95
+ if (pinned) store()?.setItem(IMAGE_ORIGINAL_KEY, 'original');
96
+ else store()?.removeItem(IMAGE_ORIGINAL_KEY);
97
+ } catch {
98
+ /* a full or refused store only means the choice lasts this picture */
99
+ }
100
+ }
101
+
102
+ /** What a viewer's control shows, and the one way to change it. */
103
+ export interface ImageOriginalControl {
104
+ /** The remembered "always the original". */
105
+ pinned: boolean;
106
+ /** The original is the picture on screen right now. */
107
+ showing: boolean;
108
+ /** The original is being fetched and decoded; the web copy is still on screen. */
109
+ loading: boolean;
110
+ /** Why the original cannot be drawn here, or null when it can. */
111
+ unavailable: string | null;
112
+ /** Flip the remembered choice, and apply it. */
113
+ toggle: () => void;
114
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `cursedbelt/react/media/image-original` — a picture's original past its web copy (forge task
3
+ * 3873), for an app that draws its own `<img>` and must not bundle the media barrel's optional
4
+ * peers (photos' zoomable asset page). The gallery stage uses the same three modules.
5
+ */
6
+ export { type ImageOriginalControl, originalUndrawableReason } from './imageOriginal.js';
7
+ export { ImageOriginalToggle, type ImageOriginalToggleProps } from './ImageOriginalToggle.js';
8
+ export { type ImageOriginal, type UseImageOriginalOptions, useImageOriginal } from './useImageOriginal.js';
@@ -164,6 +164,15 @@ export { useObjectUrl } from './useObjectUrl.js';
164
164
  // element rather than Vidstack and each was carrying its own copy of the decision.
165
165
  export { attachHlsSource, canPlayHlsNatively, type HlsSourceUrls, useHlsSource } from './hlsSource.js';
166
166
  export { PLAYBACK_RATES, preferBundledHls, VideoPlayer, type VideoPlayerProps } from './VideoPlayer.js';
167
+ // The quality menu — Auto · Max · each rung, remembered per viewer (forge task 3868); every
168
+ // player surface in this package draws it, and a control is what a surface hands it.
169
+ export type { VideoQualityControl } from './videoQuality.js';
170
+ export { VideoQualitySelect, type VideoQualitySelectProps } from './VideoQualitySelect.js';
171
+ // The image half of it — the web copy, and the original past it, remembered per viewer
172
+ // (forge task 3873); an image viewer draws the `src` the hook answers and hands it the toggle.
173
+ export { type ImageOriginalControl, originalUndrawableReason } from './imageOriginal.js';
174
+ export { ImageOriginalToggle, type ImageOriginalToggleProps } from './ImageOriginalToggle.js';
175
+ export { type ImageOriginal, type UseImageOriginalOptions, useImageOriginal } from './useImageOriginal.js';
167
176
  // The focus viewer — look closely at PART of a video, and keep what you find. Lifted out
168
177
  // of apps/collections in unit 15 so binary-server's inspector can mount the same one.
169
178
  export {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The web copy, and the original past it — `imageOriginal.ts` says what the choices mean.
3
+ *
4
+ * A viewer hands this what it already knows — the two URLs, the picture's type, how wide it is
5
+ * DRAWN right now (zoom included), and how wide the web copy is — and draws the `src` it
6
+ * answers. Nothing here measures the DOM: the gallery stage measures its `<img>`, photos' zoom
7
+ * multiplies its fit by its scale, and either way the decision is the same pure comparison.
8
+ */
9
+ import { useCallback, useEffect, useState } from 'react';
10
+ import {
11
+ type ImageOriginalControl,
12
+ ORIGINAL_FAILED_REASON,
13
+ originalNeeded,
14
+ originalUndrawableReason,
15
+ readImageOriginalPinned,
16
+ writeImageOriginalPinned,
17
+ } from './imageOriginal.js';
18
+
19
+ export interface UseImageOriginalOptions {
20
+ /** The web copy — what is drawn until the original is wanted AND decoded. */
21
+ src: string;
22
+ /** The original, signed for inline viewing. Absent: there is no original to reach, and no control. */
23
+ originalSrc?: string | null | undefined;
24
+ mime?: string | null | undefined;
25
+ name?: string | null | undefined;
26
+ /** The picture's width on screen in CSS pixels, zoom included; 0 while unknown. */
27
+ drawnWidth: number;
28
+ /** The web copy's own pixel width (`naturalWidth`); 0 while unknown. */
29
+ webWidth: number;
30
+ }
31
+
32
+ export interface ImageOriginal {
33
+ /** The URL to draw. */
34
+ src: string;
35
+ /** Null when the item carries no original. */
36
+ control: ImageOriginalControl | null;
37
+ }
38
+
39
+ /** Off-screen fetch AND decode, so the swap is one frame. The browser's cache serves the `<img>` after. */
40
+ function decodeOff(url: string): Promise<void> {
41
+ const image = new Image();
42
+ image.decoding = 'async';
43
+ image.src = url;
44
+ return image.decode();
45
+ }
46
+
47
+ export function useImageOriginal({ src, originalSrc, mime, name, drawnWidth, webWidth }: UseImageOriginalOptions): ImageOriginal {
48
+ const [pinned, setPinned] = useState(readImageOriginalPinned);
49
+ /** The original that has decoded and may be drawn — keyed by URL, so a new picture starts over. */
50
+ const [ready, setReady] = useState<string | null>(null);
51
+ const [failed, setFailed] = useState<string | null>(null);
52
+ /**
53
+ * 🔴 Latched per original: once a zoom has fetched it, zooming back out keeps it. The bytes
54
+ * are already here, and swapping back to the web copy would only flicker.
55
+ */
56
+ const [reached, setReached] = useState<string | null>(null);
57
+
58
+ const original = originalSrc || null;
59
+ const undrawable = original ? originalUndrawableReason(mime, name) : null;
60
+ const unavailable = undrawable ?? (original && failed === original ? ORIGINAL_FAILED_REASON : null);
61
+ const ratio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
62
+ const needed = originalNeeded(drawnWidth, webWidth, ratio);
63
+ const wanted = Boolean(original) && !unavailable && (pinned || needed || reached === original);
64
+
65
+ useEffect(() => {
66
+ if (wanted && original && reached !== original) setReached(original);
67
+ }, [wanted, original, reached]);
68
+
69
+ useEffect(() => {
70
+ if (!wanted || !original || ready === original) return;
71
+ let live = true;
72
+ decodeOff(original).then(
73
+ () => live && setReady(original),
74
+ () => live && setFailed(original),
75
+ );
76
+ return () => {
77
+ live = false;
78
+ };
79
+ }, [wanted, original, ready]);
80
+
81
+ const toggle = useCallback(() => {
82
+ setPinned((was) => {
83
+ writeImageOriginalPinned(!was);
84
+ return !was;
85
+ });
86
+ // Un-pinning returns to the web copy unless the zoom still needs the original.
87
+ setReached(null);
88
+ }, []);
89
+
90
+ const showing = Boolean(original) && wanted && ready === original;
91
+ return {
92
+ src: showing && original ? original : src,
93
+ control: original
94
+ ? { pinned, showing, loading: wanted && !showing, unavailable, toggle }
95
+ : null,
96
+ };
97
+ }
@@ -70,3 +70,26 @@
70
70
  --media-sm-fullscreen-button-size: 44px;
71
71
  }
72
72
  }
73
+
74
+ /*
75
+ * ── The quality menu (forge task 3868) ────────────────────────────────────────────────
76
+ * cursedbelt's own (`VideoQualitySelect`, in the bar beside the cog) REPLACES Vidstack's
77
+ * quality submenu: that one offers no Max and remembers nothing, and two menus choosing the
78
+ * same thing would disagree. This stylesheet ships only with `VideoPlayer`, which always
79
+ * draws the replacement, so hiding Vidstack's here cannot leave a player with neither.
80
+ */
81
+ .vds-quality-menu {
82
+ display: none;
83
+ }
84
+ .cbvp-quality {
85
+ align-self: center;
86
+ color: inherit;
87
+ font-size: 13px;
88
+ }
89
+ /* The trigger is as tall as the bar's own buttons — 44px on a finger (the coarse block above
90
+ sets `--media-button-size`), so the chip clears the same tap floor they do. */
91
+ .cbvp-quality button {
92
+ min-height: var(--media-button-size, 40px);
93
+ min-width: 44px;
94
+ padding-inline: 6px;
95
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 🔴 The quality choice (forge task 3868): Auto is capped at the player's size, Max lifts the cap,
3
+ * a rung is fixed — and the choice is remembered as a HEIGHT, so it means the same on every film.
4
+ */
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+ import {
7
+ bindHlsQuality,
8
+ createVideoQualityStore,
9
+ type HlsQualityEngine,
10
+ levelIndexFor,
11
+ NO_LADDER_REASON,
12
+ parseVideoQuality,
13
+ readVideoQuality,
14
+ rungLabel,
15
+ rungLevels,
16
+ VIDEO_QUALITY_KEY,
17
+ type VideoQualityControl,
18
+ writeVideoQuality,
19
+ } from './videoQuality.js';
20
+
21
+ afterEach(() => localStorage.removeItem(VIDEO_QUALITY_KEY));
22
+
23
+ /** binary-server's ladder as hls.js parses it: three encoded rungs and the original's copy. */
24
+ const LADDER = [
25
+ { height: 360, uri: 'https://bs.test/f.hls/360/playlist.m3u8?t=x' },
26
+ { height: 720, uri: 'https://bs.test/f.hls/720/playlist.m3u8?t=x' },
27
+ { height: 1080, uri: 'https://bs.test/f.hls/1080/playlist.m3u8?t=x' },
28
+ { height: 2160, uri: 'https://bs.test/f.hls/o2160/playlist.m3u8?t=x' },
29
+ ];
30
+
31
+ function fakeHls(levels = LADDER) {
32
+ const handlers = new Map<string, ((...args: unknown[]) => void)[]>();
33
+ const engine = {
34
+ levels,
35
+ currentLevel: -1,
36
+ nextLevel: -1,
37
+ capLevelToPlayerSize: true,
38
+ on: (event: string, fn: (...args: never[]) => void) =>
39
+ handlers.set(event, [...(handlers.get(event) ?? []), fn as (...args: unknown[]) => void]),
40
+ off: (event: string, fn: (...args: never[]) => void) =>
41
+ handlers.set(event, (handlers.get(event) ?? []).filter((h) => h !== fn)),
42
+ fire: (event: string, data?: unknown) => {
43
+ for (const h of handlers.get(event) ?? []) h(event, data);
44
+ },
45
+ };
46
+ return engine as typeof engine & HlsQualityEngine;
47
+ }
48
+
49
+ describe('the remembered choice', () => {
50
+ test('round-trips auto, max and a height; anything unreadable is auto', () => {
51
+ expect(parseVideoQuality('max')).toBe('max');
52
+ expect(parseVideoQuality('1080')).toBe(1080);
53
+ for (const junk of [null, '', 'auto', '-5', '1.5', 'NaN', 'huge']) expect(parseVideoQuality(junk)).toBe('auto');
54
+ writeVideoQuality(720);
55
+ expect(readVideoQuality()).toBe(720);
56
+ writeVideoQuality('max');
57
+ expect(readVideoQuality()).toBe('max');
58
+ });
59
+ });
60
+
61
+ describe('a height means the same rung on every film', () => {
62
+ test('the exact rung, else the tallest below it, else the shortest — and -1 for adaptive', () => {
63
+ expect(levelIndexFor(LADDER, 1080)).toBe(2);
64
+ expect(levelIndexFor(LADDER, 1440)).toBe(2);
65
+ expect(levelIndexFor(LADDER, 240)).toBe(0);
66
+ expect(levelIndexFor(LADDER, 'auto')).toBe(-1);
67
+ expect(levelIndexFor(LADDER, 'max')).toBe(-1);
68
+ expect(levelIndexFor([], 720)).toBe(-1);
69
+ });
70
+
71
+ test("the o<height> rung is named the original's own stream", () => {
72
+ const levels = rungLevels(LADDER);
73
+ expect(levels.map((l) => l.height)).toEqual([2160, 1080, 720, 360]);
74
+ expect(levels.map(rungLabel)).toEqual(['2160p · Original', '1080p', '720p', '360p']);
75
+ // An encoded master (m<height>) is not the original.
76
+ expect(rungLevels([{ height: 2160, uri: 'f.hls/m2160/playlist.m3u8' }])[0]?.original).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe('bindHlsQuality drives hls.js by the choice', () => {
81
+ test('auto keeps the size cap; a remembered rung is applied when the manifest is parsed', () => {
82
+ const hls = fakeHls();
83
+ const seen: VideoQualityControl[] = [];
84
+ bindHlsQuality(hls, (c) => seen.push(c));
85
+ expect(hls.capLevelToPlayerSize).toBe(true);
86
+
87
+ writeVideoQuality(720);
88
+ const fixed = fakeHls();
89
+ bindHlsQuality(fixed, (c) => seen.push(c));
90
+ expect(fixed.capLevelToPlayerSize).toBe(false);
91
+ fixed.fire('hlsManifestParsed');
92
+ expect(fixed.currentLevel).toBe(1);
93
+ });
94
+
95
+ test('🔴 Max lifts the cap and stays adaptive; the choice is remembered; a switch reports its rung', () => {
96
+ const hls = fakeHls();
97
+ const seen: { last?: VideoQualityControl } = {};
98
+ bindHlsQuality(hls, (c) => {
99
+ seen.last = c;
100
+ });
101
+ hls.fire('hlsManifestParsed');
102
+ seen.last?.select('max');
103
+ expect(hls.capLevelToPlayerSize).toBe(false);
104
+ expect(hls.nextLevel).toBe(-1);
105
+ expect(readVideoQuality()).toBe('max');
106
+ seen.last?.select(1080);
107
+ expect(hls.nextLevel).toBe(2);
108
+ seen.last?.select('auto');
109
+ expect(hls.capLevelToPlayerSize).toBe(true);
110
+ expect(hls.nextLevel).toBe(-1);
111
+ hls.fire('hlsLevelSwitched', { level: 3 });
112
+ expect(seen.last?.playingHeight).toBe(2160);
113
+ expect(seen.last?.unavailable).toBeNull();
114
+ });
115
+
116
+ test('a one-rung stream can choose nothing, and says why', () => {
117
+ const hls = fakeHls([{ height: 720, uri: 'only/playlist.m3u8' }]);
118
+ const seen: { last?: VideoQualityControl } = {};
119
+ bindHlsQuality(hls, (c) => {
120
+ seen.last = c;
121
+ });
122
+ hls.fire('hlsManifestParsed');
123
+ expect(seen.last?.unavailable).toBe(NO_LADDER_REASON);
124
+ });
125
+ });
126
+
127
+ test('the store notifies its readers and nothing else', () => {
128
+ const store = createVideoQualityStore();
129
+ let calls = 0;
130
+ const off = store.subscribe(() => calls++);
131
+ store.set(null);
132
+ off();
133
+ store.set(null);
134
+ expect(calls).toBe(1);
135
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A video's quality choice — YouTube's menu: Auto · Max · each rung — remembered per viewer.
3
+ *
4
+ * ── Why the choice exists (forge task 3868, the owner's ruling of 2026-10-28) ─────────────
5
+ * A ladder's top rung is now the ORIGINAL's own stream wherever the browser can play it
6
+ * (binary-server's `planLadder`: an `o<height>` rung is a stream copy, no loss). hls.js was
7
+ * capped to the player's size (`capLevelToPlayerSize`, task 3219 — *"lower bandwidth usage"*),
8
+ * so a windowed player played 480/720 on any connection, which is the blur the owner kept
9
+ * seeing. Both wishes stand, so they became two choices instead of one setting:
10
+ *
11
+ * | choice | what hls.js does |
12
+ * |---|---|
13
+ * | `auto` | adapts to bandwidth, never above what the player can show (the cap ON) |
14
+ * | `max` | adapts to bandwidth, up to the original, whatever the player's size (the cap OFF) |
15
+ * | a height | that rung, fixed — the nearest rung at or below it on a film without it |
16
+ *
17
+ * ── 🔴 Remembered as a HEIGHT, never as a level index ──────────────────────────────────
18
+ * Every film has its own ladder: index 3 is 1080p on one and the 2160 original on the next.
19
+ * A height means the same thing on every film, and a film whose ladder has no such rung takes
20
+ * the nearest one below it (`levelIndexFor`) — "1080p" on a 720p film is its 720p, not
21
+ * nothing.
22
+ *
23
+ * ── 🔴 Per VIEWER, so `localStorage` ───────────────────────────────────────────────────
24
+ * A phone on cellular and the TV want different answers, and a viewer's device is where the
25
+ * answer belongs; a server-side preference would hand the TV's "Max" to the phone. Every
26
+ * surface reads the one key, so a choice made in the gallery stage carries into the focus
27
+ * viewer and the next film.
28
+ *
29
+ * ── Native HLS cannot choose ────────────────────────────────────────────────────────────
30
+ * Safari and every iPhone play the ladder with no library, and a page has no API to pick a
31
+ * rung there — the browser adapts by itself. The control stays VISIBLE and disabled, with the
32
+ * reason (`NATIVE_HLS_REASON`), per the fleet's one-UI rule.
33
+ */
34
+
35
+ /** `auto`, `max`, or a rung's height in pixels. */
36
+ export type VideoQualityChoice = 'auto' | 'max' | number;
37
+
38
+ /** The one key every player reads and writes. */
39
+ export const VIDEO_QUALITY_KEY = 'cb.video-quality';
40
+
41
+ export const NATIVE_HLS_REASON =
42
+ 'This browser plays the stream itself and picks the quality on its own — Safari and iPhone let a page choose nothing';
43
+ export const NO_LADDER_REASON = 'This video has one quality: it is playing the file itself, not a ladder';
44
+
45
+ /** The stored string back into a choice. Anything unreadable is `auto`, the safe default. */
46
+ export function parseVideoQuality(raw: string | null | undefined): VideoQualityChoice {
47
+ if (raw === 'max') return 'max';
48
+ const height = Number(raw);
49
+ return Number.isInteger(height) && height > 0 ? height : 'auto';
50
+ }
51
+
52
+ export function serializeVideoQuality(choice: VideoQualityChoice): string {
53
+ return typeof choice === 'number' ? String(choice) : choice;
54
+ }
55
+
56
+ /** Guarded: storage throws in a private window, and is absent in a worker or on the server. */
57
+ function store(): Storage | null {
58
+ try {
59
+ return globalThis.localStorage ?? null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function readVideoQuality(): VideoQualityChoice {
66
+ try {
67
+ return parseVideoQuality(store()?.getItem(VIDEO_QUALITY_KEY));
68
+ } catch {
69
+ return 'auto';
70
+ }
71
+ }
72
+
73
+ export function writeVideoQuality(choice: VideoQualityChoice): void {
74
+ try {
75
+ store()?.setItem(VIDEO_QUALITY_KEY, serializeVideoQuality(choice));
76
+ } catch {
77
+ /* a full or refused store only means the choice lasts this film */
78
+ }
79
+ }
80
+
81
+ /** One rung, as a menu offers it. */
82
+ export interface VideoQualityLevel {
83
+ height: number;
84
+ /** The original's own stream (binary-server's `o<height>` rung), not an encode. */
85
+ original: boolean;
86
+ }
87
+
88
+ /** The fields this module reads off an hls.js `Level` — declared here so hls.js stays a lazy chunk. */
89
+ export interface HlsLevelLike {
90
+ height?: number;
91
+ uri?: string;
92
+ url?: readonly string[];
93
+ }
94
+
95
+ /** A rung playlist under an `o<height>/` directory is the original's stream copy. */
96
+ const ORIGINAL_RUNG = /(^|\/)o\d+\/[^/]*$/;
97
+
98
+ /** The ladder's rungs, tallest first, one per height. */
99
+ export function rungLevels(levels: readonly HlsLevelLike[]): VideoQualityLevel[] {
100
+ const byHeight = new Map<number, VideoQualityLevel>();
101
+ for (const level of levels) {
102
+ const height = level.height ?? 0;
103
+ if (height <= 0) continue;
104
+ const uri = (level.uri ?? level.url?.[0] ?? '').split('?')[0] ?? '';
105
+ const original = ORIGINAL_RUNG.test(uri);
106
+ const had = byHeight.get(height);
107
+ byHeight.set(height, { height, original: original || (had?.original ?? false) });
108
+ }
109
+ return [...byHeight.values()].sort((a, b) => b.height - a.height);
110
+ }
111
+
112
+ /**
113
+ * The hls.js level a choice plays: `-1` (adaptive) for `auto` and `max`, else the rung of that
114
+ * height — or the tallest below it, or the shortest when every rung is taller.
115
+ */
116
+ export function levelIndexFor(levels: readonly HlsLevelLike[], choice: VideoQualityChoice): number {
117
+ if (typeof choice !== 'number' || levels.length === 0) return -1;
118
+ let best = -1;
119
+ let shortest = -1;
120
+ levels.forEach((level, index) => {
121
+ const height = level.height ?? 0;
122
+ if (shortest === -1 || height < (levels[shortest]?.height ?? 0)) shortest = index;
123
+ if (height <= choice && (best === -1 || height > (levels[best]?.height ?? 0))) best = index;
124
+ });
125
+ return best === -1 ? shortest : best;
126
+ }
127
+
128
+ /** What a player surface shows, and the one way to change it. */
129
+ export interface VideoQualityControl {
130
+ /** The ladder's rungs, tallest first. Empty when nothing can be chosen. */
131
+ levels: VideoQualityLevel[];
132
+ choice: VideoQualityChoice;
133
+ /** The rung on screen right now, when known — what "Auto" is showing. */
134
+ playingHeight: number | null;
135
+ /** Why nothing can be chosen here, or null when the menu works. */
136
+ unavailable: string | null;
137
+ /** Apply AND remember. */
138
+ select: (choice: VideoQualityChoice) => void;
139
+ }
140
+
141
+ /** A control that can choose nothing, with the reason. */
142
+ export function unavailableQuality(reason: string): VideoQualityControl {
143
+ return { levels: [], choice: readVideoQuality(), playingHeight: null, unavailable: reason, select: writeVideoQuality };
144
+ }
145
+
146
+ /** The slice of an hls.js instance this module drives. */
147
+ export interface HlsQualityEngine {
148
+ levels: readonly HlsLevelLike[] | undefined;
149
+ currentLevel: number;
150
+ nextLevel: number;
151
+ capLevelToPlayerSize: boolean;
152
+ on: (event: string, listener: (...args: never[]) => void) => void;
153
+ off: (event: string, listener: (...args: never[]) => void) => void;
154
+ }
155
+
156
+ /** hls.js's event names, as strings, so this module never imports the library. */
157
+ const MANIFEST_PARSED = 'hlsManifestParsed';
158
+ const LEVEL_SWITCHED = 'hlsLevelSwitched';
159
+
160
+ /**
161
+ * Drive `hls` by the viewer's remembered choice, and report it.
162
+ *
163
+ * The cap is set before the first fragment (a `max` viewer never loads a capped first rung),
164
+ * and a fixed rung is applied when the manifest is parsed — the first moment the levels exist.
165
+ * A later choice switches with `nextLevel`: the next fragment plays the new rung, and what is
166
+ * already buffered still plays, so changing quality never stalls the picture.
167
+ *
168
+ * Returns the unbind. `engine.destroy()` drops the listeners too; the unbind is for a surface
169
+ * that keeps the engine.
170
+ */
171
+ export function bindHlsQuality(
172
+ hls: HlsQualityEngine,
173
+ onChange: (control: VideoQualityControl) => void,
174
+ ): () => void {
175
+ let choice = readVideoQuality();
176
+ let playingHeight: number | null = null;
177
+ // Read through this: an engine that has not parsed a manifest (or a spec's fake) has none.
178
+ const levels = (): readonly HlsLevelLike[] => hls.levels ?? [];
179
+ hls.capLevelToPlayerSize = choice === 'auto';
180
+
181
+ const emit = (): void =>
182
+ onChange({
183
+ levels: rungLevels(levels()),
184
+ choice,
185
+ playingHeight,
186
+ unavailable: levels().length > 1 ? null : NO_LADDER_REASON,
187
+ select,
188
+ });
189
+
190
+ const select = (next: VideoQualityChoice): void => {
191
+ choice = next;
192
+ writeVideoQuality(next);
193
+ hls.capLevelToPlayerSize = next === 'auto';
194
+ hls.nextLevel = levelIndexFor(levels(), next);
195
+ emit();
196
+ };
197
+
198
+ const onParsed = (): void => {
199
+ const index = levelIndexFor(levels(), choice);
200
+ if (index !== -1) hls.currentLevel = index;
201
+ emit();
202
+ };
203
+ const onSwitched = (_event: unknown, data: { level?: number }): void => {
204
+ const level = typeof data?.level === 'number' ? levels()[data.level] : undefined;
205
+ playingHeight = level?.height ?? null;
206
+ emit();
207
+ };
208
+ hls.on(MANIFEST_PARSED, onParsed);
209
+ hls.on(LEVEL_SWITCHED, onSwitched as (...args: never[]) => void);
210
+ if (levels().length > 0) emit();
211
+ return () => {
212
+ hls.off(MANIFEST_PARSED, onParsed);
213
+ hls.off(LEVEL_SWITCHED, onSwitched as (...args: never[]) => void);
214
+ };
215
+ }
216
+
217
+ /** A rung's menu label: `2160p · Original`, `720p`. */
218
+ export function rungLabel(level: VideoQualityLevel): string {
219
+ return level.original ? `${level.height}p · Original` : `${level.height}p`;
220
+ }
221
+
222
+ /**
223
+ * A quality control held OUTSIDE React state, for a surface whose menu lives in a slot it must
224
+ * not rebuild: Vidstack's layout remounts whenever its `slots` object changes (`VideoPlayer`'s
225
+ * memo says why), and the control changes on every rung switch. The slot reads this store with
226
+ * `useSyncExternalStore`, so a switch re-renders the menu and nothing else.
227
+ */
228
+ export interface VideoQualityStore {
229
+ get: () => VideoQualityControl | null;
230
+ set: (control: VideoQualityControl | null) => void;
231
+ subscribe: (listener: () => void) => () => void;
232
+ }
233
+
234
+ export function createVideoQualityStore(): VideoQualityStore {
235
+ let current: VideoQualityControl | null = null;
236
+ const listeners = new Set<() => void>();
237
+ return {
238
+ get: () => current,
239
+ set: (control) => {
240
+ current = control;
241
+ for (const listener of listeners) listener();
242
+ },
243
+ subscribe: (listener) => {
244
+ listeners.add(listener);
245
+ return () => listeners.delete(listener);
246
+ },
247
+ };
248
+ }