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.
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +3 -2
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/ImageOriginalToggle.d.ts +22 -0
- package/dist/react/media/ImageOriginalToggle.d.ts.map +1 -0
- package/dist/react/media/ImageOriginalToggle.js +16 -0
- package/dist/react/media/ImageOriginalToggle.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +9 -3
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoQualitySelect.d.ts +24 -0
- package/dist/react/media/VideoQualitySelect.d.ts.map +1 -0
- package/dist/react/media/VideoQualitySelect.js +34 -0
- package/dist/react/media/VideoQualitySelect.js.map +1 -0
- package/dist/react/media/hlsSource.d.ts +14 -2
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +27 -6
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/imageOriginal.d.ts +69 -0
- package/dist/react/media/imageOriginal.d.ts.map +1 -0
- package/dist/react/media/imageOriginal.js +98 -0
- package/dist/react/media/imageOriginal.js.map +1 -0
- package/dist/react/media/imageOriginalEntry.d.ts +9 -0
- package/dist/react/media/imageOriginalEntry.d.ts.map +1 -0
- package/dist/react/media/imageOriginalEntry.js +9 -0
- package/dist/react/media/imageOriginalEntry.js.map +1 -0
- package/dist/react/media/index.d.ts +5 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/useImageOriginal.d.ts +21 -0
- package/dist/react/media/useImageOriginal.d.ts.map +1 -0
- package/dist/react/media/useImageOriginal.js +63 -0
- package/dist/react/media/useImageOriginal.js.map +1 -0
- package/dist/react/media/videoPlayer.css +23 -0
- package/dist/react/media/videoQuality.d.ts +113 -0
- package/dist/react/media/videoQuality.d.ts.map +1 -0
- package/dist/react/media/videoQuality.js +185 -0
- package/dist/react/media/videoQuality.js.map +1 -0
- package/dist/react/media/vidstackQuality.d.ts +27 -0
- package/dist/react/media/vidstackQuality.d.ts.map +1 -0
- package/dist/react/media/vidstackQuality.js +51 -0
- package/dist/react/media/vidstackQuality.js.map +1 -0
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +42 -10
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +42 -0
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -2
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +10 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/react/sharing/SharePanel.d.ts.map +1 -1
- package/dist/react/sharing/SharePanel.js +2 -1
- package/dist/react/sharing/SharePanel.js.map +1 -1
- package/package.json +8 -2
- package/scripts/paths.ts +1 -1
- package/scripts/styleAreas.ts +2 -1
- package/src/react/media/FocusViewer.tsx +9 -1
- package/src/react/media/ImageOriginalToggle.tsx +51 -0
- package/src/react/media/VideoPlayer.spec.tsx +44 -1
- package/src/react/media/VideoPlayer.tsx +17 -3
- package/src/react/media/VideoQualitySelect.spec.tsx +47 -0
- package/src/react/media/VideoQualitySelect.tsx +81 -0
- package/src/react/media/hlsSource.spec.ts +10 -0
- package/src/react/media/hlsSource.ts +41 -7
- package/src/react/media/imageOriginal.spec.tsx +183 -0
- package/src/react/media/imageOriginal.ts +114 -0
- package/src/react/media/imageOriginalEntry.ts +8 -0
- package/src/react/media/index.ts +9 -0
- package/src/react/media/useImageOriginal.ts +97 -0
- package/src/react/media/videoPlayer.css +23 -0
- package/src/react/media/videoQuality.spec.ts +135 -0
- package/src/react/media/videoQuality.ts +248 -0
- package/src/react/media/vidstackQuality.tsx +65 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +26 -0
- package/src/react/media-gallery/MediaGallery.tsx +57 -17
- package/src/react/media-gallery/mediaGallery.css +42 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -2
- package/src/react/media-gallery/types.ts +10 -0
- 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
|
-
|
|
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 {
|
|
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={
|
|
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
|
-
|
|
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 (
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
+
});
|