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,51 @@
1
+ /**
2
+ * The "Original" toggle every image viewer shows beside its picture (forge task 3873).
3
+ * `imageOriginal.ts` holds what the choice MEANS; this is only how it is pressed.
4
+ *
5
+ * A pressed Button, because the choice is on/off — "always the original", or the web copy with
6
+ * the original once a zoom needs it — and it reads the way the video menu's "2160p · Original"
7
+ * does. 🔴 Never hidden: where the original cannot be drawn (a HEIC, a RAW) it is refused with
8
+ * the reason, which reaches a phone on a tap (`unavailableReason`).
9
+ */
10
+ import type { ComponentPropsWithoutRef } from 'react';
11
+ import { Button } from '../components/Button.js';
12
+ import type { ImageOriginalControl } from './imageOriginal.js';
13
+
14
+ export interface ImageOriginalToggleProps extends Omit<ComponentPropsWithoutRef<'button'>, 'children' | 'onClick'> {
15
+ /** From `useImageOriginal`. Null — the item has no original — draws nothing. */
16
+ control: ImageOriginalControl | null;
17
+ className?: string;
18
+ /** `ghost` inside a toolbar of ghost buttons (photos' zoom bar); `secondary` on its own over a picture. */
19
+ variant?: 'secondary' | 'ghost';
20
+ /** `md` beside a toolbar's other `md` buttons, so the row is one height (DESIGN FND-4); `sm` on its own over a picture. */
21
+ size?: 'sm' | 'md';
22
+ }
23
+
24
+ /** The hint for a live toggle: what pressing it does from here. */
25
+ function hint(control: ImageOriginalControl): string {
26
+ if (control.pinned) return 'Showing the original at full resolution — press to show the web copy until you zoom past it';
27
+ return control.showing
28
+ ? 'Showing the original, because the zoom needed it — press to always show it'
29
+ : 'Showing the web copy; the original loads when you zoom past it — press to always show it';
30
+ }
31
+
32
+ export function ImageOriginalToggle({ control, className, variant = 'secondary', size = 'sm', ...rest }: ImageOriginalToggleProps) {
33
+ if (!control) return null;
34
+ return (
35
+ <Button
36
+ {...rest}
37
+ aria-pressed={control.pinned}
38
+ className={className}
39
+ data-showing={control.showing ? 'original' : 'web'}
40
+ loading={control.loading}
41
+ onClick={control.toggle}
42
+ size={size}
43
+ tooltip={hint(control)}
44
+ // A refused press is the reason's tap on a phone; a live one's words are already on it.
45
+ {...(control.unavailable ? { unavailableReason: control.unavailable, tooltipMobile: 'tap' as const } : {})}
46
+ variant={variant}
47
+ >
48
+ Original
49
+ </Button>
50
+ );
51
+ }
@@ -19,6 +19,7 @@ import {
19
19
  useLiveScrub,
20
20
  VideoPlayer,
21
21
  } from './VideoPlayer.js';
22
+ import { bindVidstackQuality } from './vidstackQuality.js';
22
23
 
23
24
  afterEach(cleanup);
24
25
 
@@ -654,7 +655,11 @@ describe('🔴 hls.js is served from this bundle, never from a CDN', () => {
654
655
 
655
656
  test('the player wires it — an unwired prop leaves the CDN default in place', () => {
656
657
  const source = readFileSync(join(import.meta.dir, 'VideoPlayer.tsx'), 'utf8');
657
- expect(/onProviderChange=\{preferBundledHls\}/.test(source)).toBe(true);
658
+ // Either the bare prop or a handler whose FIRST act is the call — the quality binding
659
+ // (task 3868) rides the same handler, after it.
660
+ expect(
661
+ /onProviderChange=\{(preferBundledHls\}|\(provider\) => \{\s*preferBundledHls\(provider\);)/.test(source),
662
+ ).toBe(true);
658
663
  });
659
664
 
660
665
  test('hls.js is imported LAZILY — a static import lands it in every consumer\'s entry chunk', () => {
@@ -681,3 +686,41 @@ describe('🔴 an answered source is not a slow one', () => {
681
686
  expect(queryByRole('status')).toBeNull();
682
687
  });
683
688
  });
689
+
690
+ /**
691
+ * 🔴 The stage's quality choice (forge task 3868): Vidstack's hls.js gets the viewer's cap
692
+ * BEFORE it is built, and every engine it builds is bound to the menu's store.
693
+ */
694
+ describe('bindVidstackQuality', () => {
695
+ test("Auto caps Vidstack's hls.js too, Max does not, and each instance is bound", async () => {
696
+ const { createVideoQualityStore, VIDEO_QUALITY_KEY } = await import('./videoQuality.js');
697
+ const store = createVideoQualityStore();
698
+ const bound: unknown[] = [];
699
+ const provider = (): { $$PROVIDER_TYPE: string; config: Record<string, unknown>; onInstance: (cb: unknown) => void } => ({
700
+ $$PROVIDER_TYPE: 'HLS',
701
+ config: { keep: 1 },
702
+ onInstance: (cb) => bound.push(cb),
703
+ });
704
+ localStorage.removeItem(VIDEO_QUALITY_KEY);
705
+ const auto = provider();
706
+ bindVidstackQuality(auto as never, store, true);
707
+ expect(auto.config).toEqual({ keep: 1, capLevelToPlayerSize: true });
708
+ localStorage.setItem(VIDEO_QUALITY_KEY, 'max');
709
+ const max = provider();
710
+ bindVidstackQuality(max as never, store, true);
711
+ expect(max.config.capLevelToPlayerSize).toBe(false);
712
+ expect(bound).toHaveLength(2);
713
+ localStorage.removeItem(VIDEO_QUALITY_KEY);
714
+ });
715
+
716
+ test("Safari's own HLS and the progressive copy say why nothing can be chosen", async () => {
717
+ const { createVideoQualityStore, NATIVE_HLS_REASON, NO_LADDER_REASON } = await import('./videoQuality.js');
718
+ const store = createVideoQualityStore();
719
+ bindVidstackQuality({ $$PROVIDER_TYPE: 'VIDEO' } as never, store, true);
720
+ expect(store.get()?.unavailable).toBe(NATIVE_HLS_REASON);
721
+ bindVidstackQuality({ $$PROVIDER_TYPE: 'VIDEO' } as never, store, false);
722
+ expect(store.get()?.unavailable).toBe(NO_LADDER_REASON);
723
+ bindVidstackQuality(null, store, true);
724
+ expect(store.get()).toBeNull();
725
+ });
726
+ });
@@ -17,7 +17,14 @@ import {
17
17
  type DefaultVideoLayoutSlots,
18
18
  defaultLayoutIcons,
19
19
  } from '@vidstack/react/player/layouts/default';
20
- import { type MouseEvent as ReactMouseEvent, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
20
+ import {
21
+ type MouseEvent as ReactMouseEvent,
22
+ type RefObject,
23
+ useEffect,
24
+ useMemo,
25
+ useRef,
26
+ useState,
27
+ } from 'react';
21
28
  import type { PlayableVideo } from 'cursedbelt-core/media';
22
29
  import {
23
30
  PLAYER_VOLUME_KEY,
@@ -35,6 +42,8 @@ import { BarButton, mergeSlots, sectionSlots } from './controlBar.js';
35
42
  import { takePlayhead } from './playheadHandoff.js';
36
43
  import { isSurfaceControl } from './surfaceGesture.js';
37
44
  import { chapterSpans, type ChapteredVideo, type VideoChapter } from './videoChapters.js';
45
+ import { bindVidstackQuality, qualitySlots } from './vidstackQuality.js';
46
+ import { createVideoQualityStore } from './videoQuality.js';
38
47
 
39
48
  /**
40
49
  * The reusable video player primitive. Consumes a {@link PlayableVideo} (URLs already
@@ -683,14 +692,16 @@ export function VideoPlayer({
683
692
  // Memoized because `slots` holds React elements: a fresh object every render remounts
684
693
  // Vidstack's whole layout, which drops the control bar mid-hover and restarts its
685
694
  // auto-hide timer on every state change the player makes (i.e. constantly, while playing).
695
+ const [qualityStore] = useState(createVideoQualityStore);
686
696
  const slots = useMemo(
687
697
  () =>
688
698
  mergeSlots(
689
699
  stepSlots(onStep, canStep),
690
700
  sectionSlots(video.chapters),
701
+ qualitySlots(qualityStore),
691
702
  fillWindowSlots(fillWindow, onFillWindowChange),
692
703
  ),
693
- [onStep, canStep, video.chapters, fillWindow, onFillWindowChange],
704
+ [onStep, canStep, video.chapters, qualityStore, fillWindow, onFillWindowChange],
694
705
  );
695
706
 
696
707
  return (
@@ -719,7 +730,10 @@ export function VideoPlayer({
719
730
  // 🔴 The hls.js engine is served from this origin, not from jsdelivr. See
720
731
  // `preferBundledHls` — without it an adaptive ladder depends on a third-party CDN
721
732
  // and fails as a permanent spinner when that CDN is unreachable.
722
- onProviderChange={preferBundledHls}
733
+ onProviderChange={(provider) => {
734
+ preferBundledHls(provider);
735
+ bindVidstackQuality(provider, qualityStore, typeof src !== 'string');
736
+ }}
723
737
  /* See {@link hlsErrorIsAnswer}. Typed `unknown` on purpose: `hls.js` is an OPTIONAL
724
738
  peer, so its `ErrorData` must not become a static type dependency of this file. */
725
739
  onHlsError={(detail: unknown) => {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 🔴 The quality menu is never hidden (forge task 3868): where nothing can be chosen it is
3
+ * disabled, and where a ladder exists it offers Auto · Max · each rung, the original named.
4
+ */
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+ import { cleanup, render } from '@testing-library/react';
7
+ import { NATIVE_HLS_REASON, unavailableQuality, type VideoQualityControl } from './videoQuality.js';
8
+ import { VideoQualitySelect } from './VideoQualitySelect.js';
9
+
10
+ afterEach(cleanup);
11
+
12
+ const control = (over: Partial<VideoQualityControl> = {}): VideoQualityControl => ({
13
+ levels: [
14
+ { height: 2160, original: true },
15
+ { height: 720, original: false },
16
+ ],
17
+ choice: 'auto',
18
+ playingHeight: 720,
19
+ unavailable: null,
20
+ select: () => {},
21
+ ...over,
22
+ });
23
+
24
+ describe('VideoQualitySelect', () => {
25
+ test("Auto says the rung it is showing, and the trigger is live", () => {
26
+ const view = render(<VideoQualitySelect control={control()} />);
27
+ const trigger = view.getByLabelText('Video quality');
28
+ expect(trigger.textContent).toContain('Auto (720p)');
29
+ expect(trigger.hasAttribute('disabled')).toBe(false);
30
+ });
31
+
32
+ test('a remembered height the film lacks shows as the rung it resolves to', () => {
33
+ const view = render(<VideoQualitySelect control={control({ choice: 1080 })} />);
34
+ expect(view.getByLabelText('Video quality').textContent).toContain('720p');
35
+ });
36
+
37
+ test("🔴 Safari's own HLS: disabled and visible, not absent", () => {
38
+ const view = render(<VideoQualitySelect control={unavailableQuality(NATIVE_HLS_REASON)} />);
39
+ const trigger = view.getByLabelText('Video quality');
40
+ expect(trigger.hasAttribute('disabled')).toBe(true);
41
+ });
42
+
43
+ test('before a source is attached it is drawn disabled', () => {
44
+ const view = render(<VideoQualitySelect control={null} />);
45
+ expect(view.getByLabelText('Video quality').hasAttribute('disabled')).toBe(true);
46
+ });
47
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The quality menu every player surface shows — Auto · Max · each rung (forge task 3868).
3
+ * `videoQuality.ts` holds what the choices MEAN; this is only how one is picked.
4
+ *
5
+ * A {@link SelectField}, because choosing one value from a list is what it is for, and because
6
+ * its popover follows the full-screen element (`floatingContainer.ts`) — the one place a video's
7
+ * menu has to work.
8
+ *
9
+ * 🔴 Never hidden. Where nothing can be chosen (Safari's own HLS, a film with no ladder) it is
10
+ * disabled with the reason on hover (and on a tap), per the fleet's one-UI rule.
11
+ */
12
+ import type { ComponentPropsWithoutRef } from 'react';
13
+ import { SelectField } from '../components/SelectField.js';
14
+ import { Tooltip } from '../components/Tooltip.js';
15
+ import { cn } from '../lib/cn.js';
16
+ import {
17
+ parseVideoQuality,
18
+ rungLabel,
19
+ serializeVideoQuality,
20
+ type VideoQualityControl,
21
+ } from './videoQuality.js';
22
+
23
+ export interface VideoQualitySelectProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children' | 'id'> {
24
+ /** `null` until the surface has attached a source — drawn disabled meanwhile. */
25
+ control: VideoQualityControl | null;
26
+ /** A form-field look (the focus viewer's transport) or a bare chip (inside a control bar). */
27
+ fieldStyle?: boolean;
28
+ className?: string;
29
+ /** The trigger's id, for a `<label htmlFor>`. */
30
+ id?: string;
31
+ }
32
+
33
+ /** The trigger's text for Auto: what it is showing right now, the way YouTube says it. */
34
+ function autoLabel(playingHeight: number | null): string {
35
+ return playingHeight ? `Auto (${playingHeight}p)` : 'Auto';
36
+ }
37
+
38
+ export function VideoQualitySelect({ control, fieldStyle = true, className, id, ...rest }: VideoQualitySelectProps) {
39
+ const reason = control ? control.unavailable : 'Loading the video';
40
+ const playing = control?.playingHeight ?? null;
41
+ const groups = [
42
+ {
43
+ options: [
44
+ { value: 'auto', label: autoLabel(playing), description: "Adapts to the connection, up to the player's size" },
45
+ { value: 'max', label: 'Max', description: 'Adapts to the connection, up to the original' },
46
+ ],
47
+ },
48
+ ...(control && control.levels.length > 0
49
+ ? [{ label: 'Fixed', options: control.levels.map((level) => ({ value: String(level.height), label: rungLabel(level) })) }]
50
+ : []),
51
+ ];
52
+ const value = control ? serializeVideoQuality(control.choice) : 'auto';
53
+ // A remembered height this film has no rung for is still the viewer's choice; show it as
54
+ // the rung it resolves to rather than as a blank trigger.
55
+ const known = groups.some((group) => group.options.some((option) => option.value === value));
56
+ const shown = known ? value : String(control?.levels.find((l) => l.height <= Number(value))?.height ?? 'auto');
57
+ const field = (
58
+ <span {...rest} className={cn('inline-flex', className)}>
59
+ <SelectField
60
+ aria-label='Video quality'
61
+ className='w-auto'
62
+ disabled={Boolean(reason)}
63
+ fieldStyle={fieldStyle}
64
+ groups={groups}
65
+ {...(id ? { id } : {})}
66
+ onValueChange={(next) => control?.select(parseVideoQuality(next))}
67
+ size='sm'
68
+ value={shown}
69
+ />
70
+ </span>
71
+ );
72
+ // A disabled trigger fires no pointer events, so the reason hangs off the wrapper — and taps
73
+ // through on touch, because an iPhone is exactly where Safari's own HLS disables it.
74
+ return reason ? (
75
+ <Tooltip content={reason} mobile='tap' multiline>
76
+ {field}
77
+ </Tooltip>
78
+ ) : (
79
+ field
80
+ );
81
+ }
@@ -86,3 +86,13 @@ describe('attachHlsSource — every road ends at something playable', () => {
86
86
  }
87
87
  });
88
88
  });
89
+
90
+ describe('the quality control a surface draws (task 3868)', () => {
91
+ test("🔴 Safari's own HLS reports a menu that can choose nothing, and why — never no menu", async () => {
92
+ const { NATIVE_HLS_REASON, NO_LADDER_REASON } = await import('./videoQuality.js');
93
+ const reasons: (string | null)[] = [];
94
+ attachHlsSource(videoEl(true), { hlsUrl: LADDER, streamUrl: REMUX }, { onQuality: (c) => reasons.push(c.unavailable) })();
95
+ attachHlsSource(videoEl(true), { streamUrl: REMUX }, { onQuality: (c) => reasons.push(c.unavailable) })();
96
+ expect(reasons).toEqual([NATIVE_HLS_REASON, NO_LADDER_REASON]);
97
+ });
98
+ });
@@ -83,8 +83,17 @@
83
83
  * starting bandwidth estimate makes it open on the bottom rung and climb. Both are widened
84
84
  * below, each with its number's reason on the line.
85
85
  */
86
- import { useEffect, useRef } from 'react';
86
+ import { useEffect, useRef, useState } from 'react';
87
87
  import { probeSourceFault, type SourceFault, sourceFaultFromStatus } from './sourceFault.js';
88
+ import {
89
+ bindHlsQuality,
90
+ type HlsQualityEngine,
91
+ NATIVE_HLS_REASON,
92
+ NO_LADDER_REASON,
93
+ readVideoQuality,
94
+ unavailableQuality,
95
+ type VideoQualityControl,
96
+ } from './videoQuality.js';
88
97
 
89
98
  /**
90
99
  * The shape of an hls.js `ERROR` payload this module reads.
@@ -118,6 +127,12 @@ export interface HlsSourceHooks {
118
127
  * film that is about to start watching is worse off than one told nothing.
119
128
  */
120
129
  onFault?: (fault: SourceFault, source: HlsSourceKind) => void;
130
+ /**
131
+ * The viewer's quality choice and the ladder's rungs, each time either changes — what a
132
+ * surface's quality menu draws (`VideoQualitySelect`, task 3868). On Safari's own HLS and on
133
+ * the progressive copy it reports a control that can choose nothing, with the reason.
134
+ */
135
+ onQuality?: (control: VideoQualityControl) => void;
121
136
  }
122
137
 
123
138
  /** Which URLs a surface has for one video. `streamUrl` is the one that always exists. */
@@ -208,6 +223,10 @@ export const HLS_CONFIG = {
208
223
  * draw it at a quarter of its pixels. hls.js caps the level at the element's size times
209
224
  * `devicePixelRatio`, and re-evaluates when the player grows, so full screen on a 4K screen
210
225
  * still reaches the master. The owner: *"lower bandwidth usage"*.
226
+ *
227
+ * 🔴 This is the AUTO choice's cap, and only its (task 3868): the viewer's remembered choice
228
+ * (`videoQuality.ts`) turns it off for Max or a fixed rung, because a windowed player held to
229
+ * 480/720 on any connection was the blur the owner kept seeing.
211
230
  */
212
231
  capLevelToPlayerSize: true,
213
232
  /** A cold segment off the tunnel can take seconds; 20 s (the default) times out a fetch
@@ -251,6 +270,7 @@ export function attachHlsSource(
251
270
  engine?.destroy();
252
271
  engine = null;
253
272
  el.src = urls.streamUrl;
273
+ hooks.onQuality?.(unavailableQuality(NO_LADDER_REASON));
254
274
  if (at > 0) {
255
275
  // `loadedmetadata` is the first moment a seek on a fresh source is honored; setting
256
276
  // `currentTime` before it is silently dropped.
@@ -306,6 +326,7 @@ export function attachHlsSource(
306
326
  if (urls.hlsUrl && canPlayHlsNatively(el)) {
307
327
  attached = 'ladder';
308
328
  el.src = urls.hlsUrl;
329
+ hooks.onQuality?.(unavailableQuality(NATIVE_HLS_REASON));
309
330
  } else if (urls.hlsUrl) {
310
331
  // ~525 KB, and only this branch needs it — see `preferBundledHls` for why it is never a
311
332
  // top-level import and never a CDN.
@@ -314,10 +335,13 @@ export function attachHlsSource(
314
335
  if (destroyed) return;
315
336
  if (!Hls.isSupported()) {
316
337
  el.src = urls.streamUrl;
338
+ hooks.onQuality?.(unavailableQuality(NO_LADDER_REASON));
317
339
  return;
318
340
  }
319
- const hls = new Hls(HLS_CONFIG);
341
+ // The cap is the Auto choice's; a viewer who chose Max never loads a capped first rung.
342
+ const hls = new Hls({ ...HLS_CONFIG, capLevelToPlayerSize: readVideoQuality() === 'auto' });
320
343
  engine = hls;
344
+ if (hooks.onQuality) bindHlsQuality(hls as unknown as HlsQualityEngine, hooks.onQuality);
321
345
  let spent = 0;
322
346
  hls.on(Hls.Events.ERROR, (_event: unknown, data: HlsErrorData) => {
323
347
  // Non-fatal errors are hls.js's own retry machinery working. Reacting to them would
@@ -388,10 +412,13 @@ export function attachHlsSource(
388
412
  })
389
413
  .catch(() => {
390
414
  // The library is genuinely unavailable. The remux still plays.
391
- if (!destroyed) el.src = urls.streamUrl;
415
+ if (destroyed) return;
416
+ el.src = urls.streamUrl;
417
+ hooks.onQuality?.(unavailableQuality(NO_LADDER_REASON));
392
418
  });
393
419
  } else {
394
420
  el.src = urls.streamUrl;
421
+ hooks.onQuality?.(unavailableQuality(NO_LADDER_REASON));
395
422
  }
396
423
 
397
424
  return () => {
@@ -405,7 +432,8 @@ export function attachHlsSource(
405
432
  }
406
433
 
407
434
  /**
408
- * {@link attachHlsSource} as an effect, re-attaching whenever either URL changes.
435
+ * {@link attachHlsSource} as an effect, re-attaching whenever either URL changes. Returns the
436
+ * quality control for the surface's menu (`VideoQualitySelect`) — `null` until attached.
409
437
  *
410
438
  * 🔴 `onFault` is held in a REF, not listed as a dependency. A consumer passing the inline
411
439
  * arrow this hook invites would otherwise tear the engine down and re-attach it on every
@@ -415,9 +443,10 @@ export function attachHlsSource(
415
443
  export function useHlsSource(
416
444
  ref: { current: HTMLVideoElement | null },
417
445
  urls: HlsSourceUrls,
418
- hooks: HlsSourceHooks = {},
419
- ): void {
446
+ hooks: Pick<HlsSourceHooks, 'onFault'> = {},
447
+ ): VideoQualityControl | null {
420
448
  const { hlsUrl, streamUrl } = urls;
449
+ const [quality, setQuality] = useState<VideoQualityControl | null>(null);
421
450
  const { onFault } = hooks;
422
451
  const faultRef = useRef(onFault);
423
452
  useEffect(() => {
@@ -429,13 +458,18 @@ export function useHlsSource(
429
458
  useEffect(() => {
430
459
  const el = ref.current;
431
460
  if (!el) return;
461
+ setQuality(null);
432
462
  return attachHlsSource(
433
463
  el,
434
464
  { hlsUrl, streamUrl },
435
- listening ? { onFault: (fault, source) => faultRef.current?.(fault, source) } : {},
465
+ {
466
+ onQuality: setQuality,
467
+ ...(listening ? { onFault: (fault: SourceFault, source: HlsSourceKind) => faultRef.current?.(fault, source) } : {}),
468
+ },
436
469
  );
437
470
  // `ref` and `faultRef` are stable objects; the URLs are what a re-attach depends on.
438
471
  }, [ref, hlsUrl, streamUrl, listening]);
472
+ return quality;
439
473
  }
440
474
 
441
475
  /**
@@ -0,0 +1,183 @@
1
+ /**
2
+ * 🔴 A picture's original past its web copy (forge task 3873): fetched when the picture is drawn
3
+ * with more device pixels than the web copy holds or when the viewer pinned it, drawn only once
4
+ * it has DECODED, refused with the reason for a type no browser draws, and remembered per viewer.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
7
+ import { act, cleanup, render, renderHook } from '@testing-library/react';
8
+ import {
9
+ IMAGE_ORIGINAL_KEY,
10
+ ORIGINAL_FAILED_REASON,
11
+ originalNeeded,
12
+ originalUndrawableReason,
13
+ readImageOriginalPinned,
14
+ writeImageOriginalPinned,
15
+ } from './imageOriginal.js';
16
+ import { ImageOriginalToggle } from './ImageOriginalToggle.js';
17
+ import { type UseImageOriginalOptions, useImageOriginal } from './useImageOriginal.js';
18
+
19
+ const WEB = 'https://x/a.jpg.web.jpg';
20
+ const ORIGINAL = 'https://x/a.jpg';
21
+
22
+ /** Every off-screen decode, held open until the spec settles it. */
23
+ let decodes: { url: string; resolve: () => void; reject: () => void }[] = [];
24
+ const RealImage = globalThis.Image;
25
+
26
+ beforeEach(() => {
27
+ decodes = [];
28
+ localStorage.clear();
29
+ class FakeImage {
30
+ decoding = '';
31
+ src = '';
32
+ decode(): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ decodes.push({ url: this.src, resolve: () => resolve(), reject: () => reject(new Error('decode')) });
35
+ });
36
+ }
37
+ }
38
+ globalThis.Image = FakeImage as unknown as typeof Image;
39
+ });
40
+ afterEach(() => {
41
+ cleanup();
42
+ globalThis.Image = RealImage;
43
+ localStorage.clear();
44
+ });
45
+
46
+ const options = (over: Partial<UseImageOriginalOptions> = {}): UseImageOriginalOptions => ({
47
+ src: WEB,
48
+ originalSrc: ORIGINAL,
49
+ mime: 'image/jpeg',
50
+ name: 'a.jpg',
51
+ drawnWidth: 1200,
52
+ webWidth: 2560,
53
+ ...over,
54
+ });
55
+
56
+ describe('originalUndrawableReason', () => {
57
+ test('a JPEG, PNG or WebP is drawable', () => {
58
+ expect(originalUndrawableReason('image/jpeg', 'a.jpg')).toBeNull();
59
+ expect(originalUndrawableReason('image/png', 'a.png')).toBeNull();
60
+ expect(originalUndrawableReason('image/webp', null)).toBeNull();
61
+ });
62
+ test('a HEIC, a TIFF and a camera RAW are refused with the reason — by mime or by name', () => {
63
+ expect(originalUndrawableReason('image/heic', 'IMG_1.HEIC')).toContain('HEIC');
64
+ expect(originalUndrawableReason('application/octet-stream', 'IMG_1.HEIC')).toContain('HEIC');
65
+ expect(originalUndrawableReason('image/tiff', 'scan.tif')).toContain('TIFF');
66
+ expect(originalUndrawableReason('image/x-adobe-dng', 'a.dng')).toContain('camera RAW');
67
+ expect(originalUndrawableReason('', 'DSC_1.NEF')).toContain('camera RAW');
68
+ });
69
+ });
70
+
71
+ describe('originalNeeded', () => {
72
+ test('a picture drawn inside the web copy needs nothing; past it, it needs the original', () => {
73
+ expect(originalNeeded(1200, 2560, 1)).toBe(false);
74
+ expect(originalNeeded(1200, 2560, 2)).toBe(false); // 2400 device px — inside the copy
75
+ expect(originalNeeded(1600, 2560, 2)).toBe(true); // a 5K screen, or a 1.33× zoom on a retina one
76
+ expect(originalNeeded(6000, 2560, 1)).toBe(true); // a zoom
77
+ });
78
+ test('nothing measured yet is never a reason to fetch 20 MB', () => {
79
+ expect(originalNeeded(0, 2560, 2)).toBe(false);
80
+ expect(originalNeeded(6000, 0, 2)).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('the remembered choice', () => {
85
+ test('is one key, per viewer, and absent means auto', () => {
86
+ expect(readImageOriginalPinned()).toBe(false);
87
+ writeImageOriginalPinned(true);
88
+ expect(localStorage.getItem(IMAGE_ORIGINAL_KEY)).toBe('original');
89
+ expect(readImageOriginalPinned()).toBe(true);
90
+ writeImageOriginalPinned(false);
91
+ expect(localStorage.getItem(IMAGE_ORIGINAL_KEY)).toBeNull();
92
+ });
93
+ });
94
+
95
+ describe('useImageOriginal', () => {
96
+ test('inside the web copy it draws the web copy and fetches nothing', () => {
97
+ const { result } = renderHook(() => useImageOriginal(options()));
98
+ expect(result.current.src).toBe(WEB);
99
+ expect(result.current.control?.showing).toBe(false);
100
+ expect(decodes).toHaveLength(0);
101
+ });
102
+
103
+ test('zoomed past the web copy it fetches the original — and keeps the web copy until it has decoded', async () => {
104
+ const { result, rerender } = renderHook((props: UseImageOriginalOptions) => useImageOriginal(props), {
105
+ initialProps: options(),
106
+ });
107
+ rerender(options({ drawnWidth: 6000 }));
108
+ expect(decodes.map((d) => d.url)).toEqual([ORIGINAL]);
109
+ expect(result.current.src).toBe(WEB);
110
+ expect(result.current.control?.loading).toBe(true);
111
+ await act(async () => decodes[0]?.resolve());
112
+ expect(result.current.src).toBe(ORIGINAL);
113
+ expect(result.current.control?.showing).toBe(true);
114
+ // Zooming back out keeps it: the bytes are here, and swapping back would only flicker.
115
+ rerender(options({ drawnWidth: 1200 }));
116
+ expect(result.current.src).toBe(ORIGINAL);
117
+ });
118
+
119
+ test('pinned, it draws the original at any size, and the pin is remembered', async () => {
120
+ const { result } = renderHook(() => useImageOriginal(options()));
121
+ act(() => result.current.control?.toggle());
122
+ expect(readImageOriginalPinned()).toBe(true);
123
+ expect(result.current.control?.pinned).toBe(true);
124
+ await act(async () => decodes[0]?.resolve());
125
+ expect(result.current.src).toBe(ORIGINAL);
126
+ act(() => result.current.control?.toggle());
127
+ expect(result.current.src).toBe(WEB);
128
+ });
129
+
130
+ test('a HEIC original is never fetched, and the control says why', () => {
131
+ writeImageOriginalPinned(true);
132
+ const { result } = renderHook(() =>
133
+ useImageOriginal(options({ mime: 'image/heic', name: 'IMG_1.HEIC', drawnWidth: 6000 })),
134
+ );
135
+ expect(result.current.src).toBe(WEB);
136
+ expect(result.current.control?.unavailable).toContain('HEIC');
137
+ expect(decodes).toHaveLength(0);
138
+ });
139
+
140
+ test('an original that fails to decode leaves the web copy up and says so', async () => {
141
+ const { result } = renderHook(() => useImageOriginal(options({ drawnWidth: 6000 })));
142
+ await act(async () => decodes[0]?.reject());
143
+ expect(result.current.src).toBe(WEB);
144
+ expect(result.current.control?.unavailable).toBe(ORIGINAL_FAILED_REASON);
145
+ });
146
+
147
+ test('no original, no control', () => {
148
+ const { result } = renderHook(() => useImageOriginal(options({ originalSrc: undefined, drawnWidth: 6000 })));
149
+ expect(result.current.control).toBeNull();
150
+ expect(result.current.src).toBe(WEB);
151
+ });
152
+ });
153
+
154
+ describe('ImageOriginalToggle', () => {
155
+ const control = {
156
+ pinned: false,
157
+ showing: false,
158
+ loading: false,
159
+ unavailable: null as string | null,
160
+ toggle: () => {},
161
+ };
162
+ test('a pressed toggle named Original, pressed when pinned', () => {
163
+ const view = render(<ImageOriginalToggle control={{ ...control, pinned: true }} />);
164
+ const button = view.getByRole('button', { name: 'Original' });
165
+ expect(button.getAttribute('aria-pressed')).toBe('true');
166
+ expect(button.getAttribute('aria-disabled')).not.toBe('true');
167
+ });
168
+ test('refused — visible, disabled — where the original cannot be drawn', () => {
169
+ const reason = originalUndrawableReason('image/heic', 'a.heic') as string;
170
+ const view = render(<ImageOriginalToggle control={{ ...control, unavailable: reason }} />);
171
+ expect(view.getByRole('button', { name: /Original/ }).getAttribute('aria-disabled')).toBe('true');
172
+ });
173
+ test('one height with the toolbar it sits in: md when asked, sm on its own', () => {
174
+ const md = render(<ImageOriginalToggle control={control} size='md' />).getByRole('button', { name: 'Original' });
175
+ cleanup();
176
+ const sm = render(<ImageOriginalToggle control={control} />).getByRole('button', { name: 'Original' });
177
+ expect(md.className).not.toBe(sm.className);
178
+ });
179
+ test('no control, nothing drawn', () => {
180
+ const view = render(<ImageOriginalToggle control={null} />);
181
+ expect(view.container.innerHTML).toBe('');
182
+ });
183
+ });