cursedbelt 4.1.0 → 4.3.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/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +25 -5
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +19 -0
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/master-lock.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/iframesMakeNoRealRequest.spec.ts +92 -0
- package/src/react/components/IsolatedFrame.spec.tsx +27 -9
- package/src/react/master-lock/MasterLockDialog.tsx +24 -5
- package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
- package/src/react/master-lock/MasterLockProvider.tsx +19 -0
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/master-lock.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
|
@@ -25,6 +25,12 @@ import {
|
|
|
25
25
|
type SessionVolume,
|
|
26
26
|
writeSessionVolume,
|
|
27
27
|
} from '../media-gallery/playbackPreferences';
|
|
28
|
+
import {
|
|
29
|
+
hlsErrorIsAnswer,
|
|
30
|
+
probeSourceFault,
|
|
31
|
+
type SourceFault,
|
|
32
|
+
SourceFaultOverlay,
|
|
33
|
+
} from './sourceFault';
|
|
28
34
|
import { isSurfaceControl } from './surfaceGesture';
|
|
29
35
|
import { chapterSpans, type ChapteredVideo, type VideoChapter } from './videoChapters';
|
|
30
36
|
|
|
@@ -47,13 +53,36 @@ import { chapterSpans, type ChapteredVideo, type VideoChapter } from './videoCha
|
|
|
47
53
|
* Vidstack's default layout from a bare bar into a YouTube-shaped one — dividers on the time
|
|
48
54
|
* slider, the current section's name above it, and a chapters menu. See {@link ChaptersTrack}.
|
|
49
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* The clip as the players in THIS package consume it — core's {@link PlayableVideo} plus the
|
|
58
|
+
* two facts cursedbelt carries that `cursedbelt-core` does not yet: the named sections
|
|
59
|
+
* ({@link ChapteredVideo}) and whether this play is a KNOWN fallback off the adaptive ladder.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 An intersection for the same reason `ChapteredVideo` is one — see `videoChapters.ts`'s
|
|
62
|
+
* header. `PlayableVideo` lives in the published `cursedbelt-core`, which this package cannot
|
|
63
|
+
* edit and read in the same commit. It only widens, so every existing source, stage and prop
|
|
64
|
+
* still typechecks untouched.
|
|
65
|
+
*/
|
|
66
|
+
export type PlayerVideo = ChapteredVideo & {
|
|
67
|
+
/**
|
|
68
|
+
* The app could not ASK its media store about this video's ladder, so it is serving the
|
|
69
|
+
* progressive copy on that ignorance — see `MediaGalleryItemMedia.playbackDegraded` for the
|
|
70
|
+
* full note and the measurements.
|
|
71
|
+
*
|
|
72
|
+
* 🔴 Absent is the ordinary state, including for every video that simply has no ladder. Only
|
|
73
|
+
* a probe that did not ANSWER sets this; "there is no ladder" is not a fault and draws
|
|
74
|
+
* nothing.
|
|
75
|
+
*/
|
|
76
|
+
playbackDegraded?: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
50
79
|
export interface VideoPlayerProps {
|
|
51
80
|
/**
|
|
52
|
-
* The clip, with every URL already resolved —
|
|
53
|
-
*
|
|
54
|
-
* still satisfies this unchanged.
|
|
81
|
+
* The clip, with every URL already resolved — its named sections when the app carries any,
|
|
82
|
+
* and whether the ladder was unreachable. {@link PlayerVideo} is `PlayableVideo` plus those
|
|
83
|
+
* two optional fields, so every existing source still satisfies this unchanged.
|
|
55
84
|
*/
|
|
56
|
-
video:
|
|
85
|
+
video: PlayerVideo;
|
|
57
86
|
/**
|
|
58
87
|
* Start playing on mount. **Defaults to `true`** — see {@link usePrefersReducedMotion} and
|
|
59
88
|
* the block on {@link AUTOPLAY_DEFAULT}.
|
|
@@ -523,10 +552,30 @@ export function VideoPlayer({
|
|
|
523
552
|
canStep = true,
|
|
524
553
|
fullscreenOnDoubleClick = true,
|
|
525
554
|
}: VideoPlayerProps) {
|
|
555
|
+
/*
|
|
556
|
+
* 🔴 What the SOURCES have answered, and why a player needs to hold that at all.
|
|
557
|
+
*
|
|
558
|
+
* Owner, 2026-09-17: *"make a 404 stop the media player spinner so I don't think it's going
|
|
559
|
+
* to eventually load."* Vidstack's layout draws a buffering spinner whenever the player is
|
|
560
|
+
* not ready, and nothing in it distinguishes "waiting" from "asked and told no" — so an item
|
|
561
|
+
* whose ladder answers `404` in 0.2 s spun until he gave up. Two pieces of state end that:
|
|
562
|
+
*
|
|
563
|
+
* · `ladderAnswered` — the master or a rung REPLIED with a status. Nothing to wait for, so
|
|
564
|
+
* the progressive copy (the one that always exists) takes over at once instead of after
|
|
565
|
+
* hls.js has retried an answer. Vidstack has no fallback of its own; this is it.
|
|
566
|
+
* · `fault` — the progressive copy answered too, so there is nothing left to play and the
|
|
567
|
+
* only useful act is to say what happened. See `sourceFault.ts`.
|
|
568
|
+
*
|
|
569
|
+
* Both are set ONLY from an answer. A timeout, a stall and a slow origin set neither and
|
|
570
|
+
* still spin, which is correct: something might still arrive.
|
|
571
|
+
*/
|
|
572
|
+
const [ladderAnswered, setLadderAnswered] = useState(false);
|
|
573
|
+
const [fault, setFault] = useState<SourceFault | null>(null);
|
|
526
574
|
// Prefer the adaptive HLS master; fall back to the progressive Range source.
|
|
527
|
-
const src =
|
|
528
|
-
|
|
529
|
-
|
|
575
|
+
const src =
|
|
576
|
+
video.hlsUrl && !ladderAnswered
|
|
577
|
+
? ({ src: video.hlsUrl, type: 'application/x-mpegurl' } as const)
|
|
578
|
+
: video.streamUrl;
|
|
530
579
|
|
|
531
580
|
// See AUTOPLAY_DEFAULT. `muted` follows `autoPlay` because an unmuted autoplay is refused
|
|
532
581
|
// by every browser and the refusal LOOKS like the spinner this batch removed — unless he
|
|
@@ -561,7 +610,10 @@ export function VideoPlayer({
|
|
|
561
610
|
const forcedMute = useRef(false);
|
|
562
611
|
|
|
563
612
|
const status = video.processingStatus;
|
|
564
|
-
|
|
613
|
+
// 🔴 `playbackDegraded` is its own reason to draw, not a processing state — a video that
|
|
614
|
+
// optimized perfectly can still be served off the progressive copy because the ladder probe
|
|
615
|
+
// did not answer, and that play has no `ProcessingStatus` at all.
|
|
616
|
+
const showProcessing = (status != null && status !== 'ready') || video.playbackDegraded === true;
|
|
565
617
|
|
|
566
618
|
// Memoized because `slots` holds React elements: a fresh object every render remounts
|
|
567
619
|
// Vidstack's whole layout, which drops the control bar mid-hover and restarts its
|
|
@@ -594,6 +646,27 @@ export function VideoPlayer({
|
|
|
594
646
|
// `preferBundledHls` — without it an adaptive ladder depends on a third-party CDN
|
|
595
647
|
// and fails as a permanent spinner when that CDN is unreachable.
|
|
596
648
|
onProviderChange={preferBundledHls}
|
|
649
|
+
/* See {@link hlsErrorIsAnswer}. Typed `unknown` on purpose: `hls.js` is an OPTIONAL
|
|
650
|
+
peer, so its `ErrorData` must not become a static type dependency of this file. */
|
|
651
|
+
onHlsError={(detail: unknown) => {
|
|
652
|
+
if (hlsErrorIsAnswer(detail)) setLadderAnswered(true);
|
|
653
|
+
}}
|
|
654
|
+
/* The element itself gave up, and what that means depends on what was playing.
|
|
655
|
+
With a LADDER in play it is the case Vidstack has no answer for at all — a browser
|
|
656
|
+
playing HLS natively (Safari, every iPhone) meeting a master that is not there, or
|
|
657
|
+
an hls.js that has spent its own six retries — and the progressive copy has not had
|
|
658
|
+
its turn yet. 🔴 Handing over is a DOWNGRADE to a stream the edge will not cache, so
|
|
659
|
+
it happens only once the ladder has actually failed, never on a stall; it is the
|
|
660
|
+
same call `hlsSource.ts` makes when its recovery budget is spent, and a playing film
|
|
661
|
+
beats a dead one. On the progressive copy there is no turn left, so the URL is
|
|
662
|
+
probed for what it actually said and the picture says it. */
|
|
663
|
+
onError={() => {
|
|
664
|
+
if (video.hlsUrl && !ladderAnswered) {
|
|
665
|
+
setLadderAnswered(true);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
void probeSourceFault(video.streamUrl).then(setFault);
|
|
669
|
+
}}
|
|
597
670
|
title={video.name}
|
|
598
671
|
poster={video.posterUrl}
|
|
599
672
|
crossOrigin
|
|
@@ -680,12 +753,19 @@ export function VideoPlayer({
|
|
|
680
753
|
</MediaPlayer>
|
|
681
754
|
|
|
682
755
|
{showProcessing ? (
|
|
683
|
-
<ProcessingIndicator
|
|
756
|
+
<ProcessingIndicator
|
|
757
|
+
status={status}
|
|
758
|
+
progress={video.processingProgress}
|
|
759
|
+
playbackDegraded={video.playbackDegraded}
|
|
760
|
+
/>
|
|
684
761
|
) : null}
|
|
762
|
+
|
|
763
|
+
{fault ? <SourceFaultOverlay fault={fault} name={video.name} /> : null}
|
|
685
764
|
</div>
|
|
686
765
|
);
|
|
687
766
|
}
|
|
688
767
|
|
|
768
|
+
|
|
689
769
|
/**
|
|
690
770
|
* The named sections, as the `kind='chapters'` text track Vidstack's default layout is already
|
|
691
771
|
* looking for.
|
|
@@ -725,86 +805,154 @@ function ChaptersTrack({ chapters }: { chapters?: readonly VideoChapter[] }) {
|
|
|
725
805
|
return <Track content={content} type='json' kind='chapters' label='Chapters' default />;
|
|
726
806
|
}
|
|
727
807
|
|
|
808
|
+
/*
|
|
809
|
+
* 🔴 Module-private, and the specs spell both sentences out rather than importing them. This
|
|
810
|
+
* is COPY: which words are on the picture is the deliverable, so an assertion that reads the
|
|
811
|
+
* constant asserts nothing — it would stay green through a rewrite of the very thing it is
|
|
812
|
+
* meant to be pinning. (They are also not worth a line of published surface, which
|
|
813
|
+
* `publicSurface.baseline` charges for.)
|
|
814
|
+
*/
|
|
815
|
+
const OPTIMIZATION_FAILED_BADGE = 'Optimization failed — playing original';
|
|
816
|
+
const PLAYBACK_DEGRADED_BADGE = 'Reduced quality — could not reach the media store';
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* The top-right corner of a video surface, as ONE stack.
|
|
820
|
+
*
|
|
821
|
+
* 🔴 A stack rather than each badge pinning itself to `top: 8; right: 8`, because both facts
|
|
822
|
+
* can be true of the same play — an optimization that failed AND a media store we could not
|
|
823
|
+
* reach — and two absolutely-positioned pills at one coordinate draw exactly on top of each
|
|
824
|
+
* other, so the second sentence is invisible precisely when there is most to say. Laid out as
|
|
825
|
+
* a column, the corner holds one, two, or (returning `null`) none.
|
|
826
|
+
*
|
|
827
|
+
* Never covers the picture: pills only, right-aligned, `pointer-events: none`, so the original
|
|
828
|
+
* bytes stay fully visible and the control bar underneath stays clickable.
|
|
829
|
+
*/
|
|
830
|
+
function VideoStatusBadges({
|
|
831
|
+
processingFailed,
|
|
832
|
+
playbackDegraded,
|
|
833
|
+
}: {
|
|
834
|
+
processingFailed?: boolean;
|
|
835
|
+
playbackDegraded?: boolean;
|
|
836
|
+
}) {
|
|
837
|
+
const lines = [
|
|
838
|
+
processingFailed ? OPTIMIZATION_FAILED_BADGE : null,
|
|
839
|
+
// 🔴 A SECOND line, never a re-use of the first. The optimization did not fail here — it
|
|
840
|
+
// may well have succeeded and we could not ask — so borrowing that sentence would put a
|
|
841
|
+
// false one on screen. See `PlayerVideo.playbackDegraded`.
|
|
842
|
+
playbackDegraded ? PLAYBACK_DEGRADED_BADGE : null,
|
|
843
|
+
].filter((line): line is string => line !== null);
|
|
844
|
+
if (lines.length === 0) return null;
|
|
845
|
+
return (
|
|
846
|
+
<div
|
|
847
|
+
style={{
|
|
848
|
+
position: 'absolute',
|
|
849
|
+
top: 8,
|
|
850
|
+
right: 8,
|
|
851
|
+
display: 'flex',
|
|
852
|
+
flexDirection: 'column',
|
|
853
|
+
alignItems: 'flex-end',
|
|
854
|
+
gap: 4,
|
|
855
|
+
pointerEvents: 'none',
|
|
856
|
+
}}
|
|
857
|
+
>
|
|
858
|
+
{lines.map((line) => (
|
|
859
|
+
<div
|
|
860
|
+
key={line}
|
|
861
|
+
role='status'
|
|
862
|
+
style={{
|
|
863
|
+
padding: '3px 8px',
|
|
864
|
+
borderRadius: 999,
|
|
865
|
+
// A corner badge scrim ON TOP of video content — dark-on-video regardless of app
|
|
866
|
+
// theme (same convention as plugin-video-gallery's modal backdrop).
|
|
867
|
+
// guardrails-ignore no-hardcoded-colors: see comment above.
|
|
868
|
+
background: 'rgba(0, 0, 0, 0.65)',
|
|
869
|
+
color: 'rgba(255, 255, 255, 0.9)',
|
|
870
|
+
fontSize: 12,
|
|
871
|
+
fontWeight: 600,
|
|
872
|
+
lineHeight: 1.4,
|
|
873
|
+
}}
|
|
874
|
+
>
|
|
875
|
+
{line}
|
|
876
|
+
</div>
|
|
877
|
+
))}
|
|
878
|
+
</div>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
728
882
|
/**
|
|
729
|
-
* The
|
|
730
|
-
* while derivatives build (indeterminate shimmer until real progress arrives),
|
|
731
|
-
*
|
|
883
|
+
* The transport state for a video surface: a thin progress bar pinned across the bottom edge
|
|
884
|
+
* while derivatives build (indeterminate shimmer until real progress arrives), plus whatever
|
|
885
|
+
* {@link VideoStatusBadges} has to say in the corner — optimization failed, and/or this play
|
|
886
|
+
* being a known fallback off the ladder. Never covers the video — the original bytes stay
|
|
732
887
|
* fully visible and playable behind it. Shared by {@link VideoPlayer} and the canvas fallback.
|
|
888
|
+
*
|
|
889
|
+
* 🔴 `status` is OPTIONAL, because a degraded play is not a processing state: a video that
|
|
890
|
+
* finished optimizing perfectly well can still be served off the progressive copy because the
|
|
891
|
+
* ladder probe did not answer. That combination has no `ProcessingStatus` to pass.
|
|
733
892
|
*/
|
|
734
893
|
export function ProcessingIndicator({
|
|
735
894
|
status,
|
|
736
895
|
progress,
|
|
896
|
+
playbackDegraded,
|
|
737
897
|
}: {
|
|
738
|
-
status
|
|
898
|
+
status?: PlayableVideo['processingStatus'];
|
|
739
899
|
progress?: number;
|
|
900
|
+
playbackDegraded?: boolean;
|
|
740
901
|
}) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
right: 8,
|
|
749
|
-
padding: '3px 8px',
|
|
750
|
-
borderRadius: 999,
|
|
751
|
-
// A corner badge scrim ON TOP of video content — dark-on-video regardless of app
|
|
752
|
-
// theme (same convention as plugin-video-gallery's modal backdrop).
|
|
753
|
-
// guardrails-ignore no-hardcoded-colors: see comment above.
|
|
754
|
-
background: 'rgba(0, 0, 0, 0.65)',
|
|
755
|
-
color: 'rgba(255, 255, 255, 0.9)',
|
|
756
|
-
fontSize: 12,
|
|
757
|
-
fontWeight: 600,
|
|
758
|
-
lineHeight: 1.4,
|
|
759
|
-
pointerEvents: 'none',
|
|
760
|
-
}}
|
|
761
|
-
>
|
|
762
|
-
Optimization failed — playing original
|
|
763
|
-
</div>
|
|
764
|
-
);
|
|
765
|
-
}
|
|
902
|
+
// The corner is one stack, so it is rendered from one place — see `VideoStatusBadges`.
|
|
903
|
+
const badges = (
|
|
904
|
+
<VideoStatusBadges processingFailed={status === 'failed'} playbackDegraded={playbackDegraded} />
|
|
905
|
+
);
|
|
906
|
+
// The bar is for work in FLIGHT. A failed, ready or absent status has nothing building, so
|
|
907
|
+
// the corner is the whole of what this component draws.
|
|
908
|
+
if (status !== 'pending' && status !== 'processing') return badges;
|
|
766
909
|
|
|
767
910
|
const clamped = Math.max(0, Math.min(100, progress ?? 0));
|
|
768
911
|
const indeterminate = clamped === 0;
|
|
769
912
|
|
|
770
913
|
return (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
aria-valuemax={100}
|
|
776
|
-
aria-valuenow={indeterminate ? undefined : Math.round(clamped)}
|
|
777
|
-
style={{
|
|
778
|
-
position: 'absolute',
|
|
779
|
-
left: 0,
|
|
780
|
-
right: 0,
|
|
781
|
-
bottom: 0,
|
|
782
|
-
height: 4,
|
|
783
|
-
// The progress bar's TRACK sits directly on the video surface, not app chrome — a
|
|
784
|
-
// translucent white track reads on any video frame regardless of app theme (the fill
|
|
785
|
-
// is already theme-token'd: `var(--vds-accent, var(--accent))`).
|
|
786
|
-
// guardrails-ignore no-hardcoded-colors: see comment above.
|
|
787
|
-
background: 'rgba(255, 255, 255, 0.25)',
|
|
788
|
-
overflow: 'hidden',
|
|
789
|
-
pointerEvents: 'none',
|
|
790
|
-
}}
|
|
791
|
-
>
|
|
792
|
-
<style>
|
|
793
|
-
{
|
|
794
|
-
'@keyframes cb-video-processing-indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}'
|
|
795
|
-
}
|
|
796
|
-
</style>
|
|
914
|
+
<>
|
|
915
|
+
{/* A video can be BOTH still optimizing and served off an unreachable ladder, so the
|
|
916
|
+
bar and the corner are siblings rather than two branches of one `if`. */}
|
|
917
|
+
{badges}
|
|
797
918
|
<div
|
|
919
|
+
role='progressbar'
|
|
920
|
+
aria-label='Optimizing video'
|
|
921
|
+
aria-valuemin={0}
|
|
922
|
+
aria-valuemax={100}
|
|
923
|
+
aria-valuenow={indeterminate ? undefined : Math.round(clamped)}
|
|
798
924
|
style={{
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
925
|
+
position: 'absolute',
|
|
926
|
+
left: 0,
|
|
927
|
+
right: 0,
|
|
928
|
+
bottom: 0,
|
|
929
|
+
height: 4,
|
|
930
|
+
// The progress bar's TRACK sits directly on the video surface, not app chrome — a
|
|
931
|
+
// translucent white track reads on any video frame regardless of app theme (the fill
|
|
932
|
+
// is already theme-token'd: `var(--vds-accent, var(--accent))`).
|
|
933
|
+
// guardrails-ignore no-hardcoded-colors: see comment above.
|
|
934
|
+
background: 'rgba(255, 255, 255, 0.25)',
|
|
935
|
+
overflow: 'hidden',
|
|
936
|
+
pointerEvents: 'none',
|
|
806
937
|
}}
|
|
807
|
-
|
|
808
|
-
|
|
938
|
+
>
|
|
939
|
+
<style>
|
|
940
|
+
{
|
|
941
|
+
'@keyframes cb-video-processing-indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(400%)}}'
|
|
942
|
+
}
|
|
943
|
+
</style>
|
|
944
|
+
<div
|
|
945
|
+
style={{
|
|
946
|
+
width: indeterminate ? '25%' : `${clamped}%`,
|
|
947
|
+
height: '100%',
|
|
948
|
+
background: 'var(--vds-accent, var(--accent))',
|
|
949
|
+
transition: indeterminate ? undefined : 'width 300ms',
|
|
950
|
+
animation: indeterminate
|
|
951
|
+
? 'cb-video-processing-indeterminate 1.4s ease-in-out infinite'
|
|
952
|
+
: undefined,
|
|
953
|
+
}}
|
|
954
|
+
/>
|
|
955
|
+
</div>
|
|
956
|
+
</>
|
|
809
957
|
);
|
|
810
958
|
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { attachHlsSource } from './hlsSource';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* *"make a 404 stop the media player spinner so I don't think it's going to eventually load"*
|
|
6
|
+
* — owner, 2026-09-17.
|
|
7
|
+
*
|
|
8
|
+
* `hlsSource.recovery.spec.ts` beside this one pins the other half: a fatal error that is a
|
|
9
|
+
* BLIP gets four recoveries and a widening backoff, because something might still arrive.
|
|
10
|
+
* This file pins the opposite case, which that machinery was getting exactly wrong — an error
|
|
11
|
+
* that is an ANSWER. The ladder in the report replied `404` in 0.2 s, and the answer went
|
|
12
|
+
* through `startLoad()` four times and twelve seconds of backoff before reaching the fallback
|
|
13
|
+
* it could have taken immediately.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 The two must not be conflated, which is why both files exist: the fix here has to leave
|
|
16
|
+
* the recoveries alone. `response.code` is the discriminator — hls.js sets it only when the
|
|
17
|
+
* server actually replied.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A `<video>` that reports native HLS or not, so both branches can be driven. */
|
|
21
|
+
function videoEl(canPlayHls = false): HTMLVideoElement {
|
|
22
|
+
const el = document.createElement('video');
|
|
23
|
+
el.canPlayType = ((type: string) =>
|
|
24
|
+
canPlayHls && type === 'application/vnd.apple.mpegurl'
|
|
25
|
+
? 'maybe'
|
|
26
|
+
: '') as HTMLVideoElement['canPlayType'];
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LADDER = 'https://example.test/clip.hls/master.m3u8';
|
|
31
|
+
const REMUX = 'https://example.test/clip.web.mp4';
|
|
32
|
+
|
|
33
|
+
const calls: string[] = [];
|
|
34
|
+
let emit: ((data: Record<string, unknown>) => void) | null = null;
|
|
35
|
+
|
|
36
|
+
/** The same fake engine `hlsSource.recovery.spec.ts` drives — happy-dom has no MediaSource. */
|
|
37
|
+
function installFakeHls(): void {
|
|
38
|
+
class Hls {
|
|
39
|
+
static Events = { ERROR: 'hlsError' } as const;
|
|
40
|
+
static ErrorTypes = {
|
|
41
|
+
NETWORK_ERROR: 'networkError',
|
|
42
|
+
MEDIA_ERROR: 'mediaError',
|
|
43
|
+
MUX_ERROR: 'muxError',
|
|
44
|
+
} as const;
|
|
45
|
+
static isSupported = (): boolean => true;
|
|
46
|
+
on(_event: string, handler: (e: unknown, d: Record<string, unknown>) => void): void {
|
|
47
|
+
emit = (d) => handler('hlsError', d);
|
|
48
|
+
}
|
|
49
|
+
loadSource(url: string): void {
|
|
50
|
+
calls.push(`loadSource:${url}`);
|
|
51
|
+
}
|
|
52
|
+
attachMedia(): void {
|
|
53
|
+
calls.push('attachMedia');
|
|
54
|
+
}
|
|
55
|
+
startLoad(): void {
|
|
56
|
+
calls.push('startLoad');
|
|
57
|
+
}
|
|
58
|
+
recoverMediaError(): void {
|
|
59
|
+
calls.push('recoverMediaError');
|
|
60
|
+
}
|
|
61
|
+
destroy(): void {
|
|
62
|
+
calls.push('destroy');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
mock.module('hls.js', () => ({ default: Hls }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Let the dynamic import resolve and its `.then` run. */
|
|
69
|
+
const settle = async (): Promise<void> => {
|
|
70
|
+
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const realFetch = globalThis.fetch;
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
globalThis.fetch = realFetch;
|
|
76
|
+
calls.length = 0;
|
|
77
|
+
emit = null;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('attachHlsSource — an answered status is not waited out', () => {
|
|
81
|
+
test('🔴 a 404 master takes the progressive copy AT ONCE, with no retry at all', async () => {
|
|
82
|
+
installFakeHls();
|
|
83
|
+
const el = videoEl(false);
|
|
84
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
85
|
+
await settle();
|
|
86
|
+
|
|
87
|
+
emit?.({
|
|
88
|
+
fatal: true,
|
|
89
|
+
type: 'networkError',
|
|
90
|
+
details: 'manifestLoadError',
|
|
91
|
+
response: { code: 404 },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(el.src).toBe(REMUX);
|
|
95
|
+
// 🔴 Not one retry. Twelve seconds of backoff in front of a reply that came in 0.2 s is
|
|
96
|
+
// the spinner the owner reported, and asking a 404 again cannot change what it says.
|
|
97
|
+
expect(calls).not.toContain('startLoad');
|
|
98
|
+
detach();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('🔴 a 403 on the ladder is an answer too — a stale token is not something to retry', async () => {
|
|
102
|
+
installFakeHls();
|
|
103
|
+
const el = videoEl(false);
|
|
104
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
105
|
+
await settle();
|
|
106
|
+
|
|
107
|
+
emit?.({ fatal: true, type: 'networkError', response: { code: 403 } });
|
|
108
|
+
|
|
109
|
+
expect(el.src).toBe(REMUX);
|
|
110
|
+
expect(calls).not.toContain('startLoad');
|
|
111
|
+
detach();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* 🔴 The guard on the whole fix. A fatal error with NO `response.code` is a timeout, an
|
|
116
|
+
* abort or a stall — the tunnel blinking, this Mac waking — and those are what the recovery
|
|
117
|
+
* budget was built for. If this test ever goes green the other way, the fix has turned a
|
|
118
|
+
* slow origin into a dead one.
|
|
119
|
+
*/
|
|
120
|
+
test('🔴 a fatal error with NO status still spends the recovery budget', async () => {
|
|
121
|
+
installFakeHls();
|
|
122
|
+
const el = videoEl(false);
|
|
123
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
124
|
+
await settle();
|
|
125
|
+
|
|
126
|
+
emit?.({ fatal: true, type: 'networkError', details: 'fragLoadTimeOut' });
|
|
127
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
128
|
+
|
|
129
|
+
expect(calls).toContain('startLoad');
|
|
130
|
+
expect(el.src).not.toBe(REMUX);
|
|
131
|
+
detach();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('attachHlsSource — the element gives up', () => {
|
|
136
|
+
/*
|
|
137
|
+
* 🔴 Before this, the native branch had NO fallback: Safari and every iPhone got
|
|
138
|
+
* `el.src = hlsUrl` and nothing else, so a 404 master on the phone was simply a dead
|
|
139
|
+
* player. The element's own `error` event is the only signal there is on that path.
|
|
140
|
+
*/
|
|
141
|
+
test('🔴 a ladder the browser plays natively falls back to the remux when it fails', () => {
|
|
142
|
+
const el = videoEl(true);
|
|
143
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
144
|
+
expect(el.src).toBe(LADDER);
|
|
145
|
+
|
|
146
|
+
el.dispatchEvent(new Event('error'));
|
|
147
|
+
|
|
148
|
+
expect(el.src).toBe(REMUX);
|
|
149
|
+
detach();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('🔴 the progressive copy failing is REPORTED, because there is nothing left to try', async () => {
|
|
153
|
+
let asked = '';
|
|
154
|
+
globalThis.fetch = ((url: string) => {
|
|
155
|
+
asked = url;
|
|
156
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
157
|
+
}) as typeof fetch;
|
|
158
|
+
|
|
159
|
+
const faults: { kind: string; status?: number; source: string }[] = [];
|
|
160
|
+
const el = videoEl(false);
|
|
161
|
+
const detach = attachHlsSource(
|
|
162
|
+
el,
|
|
163
|
+
{ streamUrl: REMUX },
|
|
164
|
+
{ onFault: (fault, source) => faults.push({ ...fault, source }) },
|
|
165
|
+
);
|
|
166
|
+
expect(el.src).toBe(REMUX);
|
|
167
|
+
|
|
168
|
+
el.dispatchEvent(new Event('error'));
|
|
169
|
+
await settle();
|
|
170
|
+
|
|
171
|
+
expect(asked).toBe(REMUX);
|
|
172
|
+
expect(faults).toEqual([{ kind: 'gone', status: 404, source: 'stream' }]);
|
|
173
|
+
detach();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/*
|
|
177
|
+
* 🔴 A missing LADDER is the ordinary state of most of a library and the remux plays, so it
|
|
178
|
+
* must never reach `onFault`. A viewer told "no longer on the media store" about a film that
|
|
179
|
+
* is about to start is worse off than one told nothing at all.
|
|
180
|
+
*/
|
|
181
|
+
test('🔴 a ladder that fails alone reports NOTHING — the remux is still to come', () => {
|
|
182
|
+
const faults: unknown[] = [];
|
|
183
|
+
const el = videoEl(true);
|
|
184
|
+
const detach = attachHlsSource(
|
|
185
|
+
el,
|
|
186
|
+
{ hlsUrl: LADDER, streamUrl: REMUX },
|
|
187
|
+
{ onFault: (fault) => faults.push(fault) },
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
el.dispatchEvent(new Event('error'));
|
|
191
|
+
|
|
192
|
+
expect(el.src).toBe(REMUX);
|
|
193
|
+
expect(faults).toEqual([]);
|
|
194
|
+
detach();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/*
|
|
198
|
+
* 🔴 The probe is bought for an ANSWER somebody will read. `CanvasVideoPlayer` and every
|
|
199
|
+
* other surface that takes the default has nowhere to put one, and a request nobody reads is
|
|
200
|
+
* a request paid for nothing — on the exact path where things are already going badly.
|
|
201
|
+
*/
|
|
202
|
+
/*
|
|
203
|
+
* 🔴 hls.js owns the element while it is attached: it listens to this same event, turns it
|
|
204
|
+
* into a fatal `MEDIA_ERROR` and waits for `recoverMediaError()`. Acting on it here as well
|
|
205
|
+
* would abandon the ladder on the first buffer stall — undoing `100206d` and putting a
|
|
206
|
+
* 467 MB uncacheable stream in front of a recovery that costs nothing.
|
|
207
|
+
*/
|
|
208
|
+
test('🔴 an element error while hls.js is driving is ITS error, not a fallback', async () => {
|
|
209
|
+
installFakeHls();
|
|
210
|
+
const el = videoEl(false);
|
|
211
|
+
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
212
|
+
await settle();
|
|
213
|
+
expect(calls).toContain('attachMedia');
|
|
214
|
+
|
|
215
|
+
el.dispatchEvent(new Event('error'));
|
|
216
|
+
|
|
217
|
+
expect(el.src).not.toBe(REMUX);
|
|
218
|
+
expect(calls).not.toContain('destroy');
|
|
219
|
+
detach();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('🔴 a surface with no `onFault` makes no probe at all', async () => {
|
|
223
|
+
let probes = 0;
|
|
224
|
+
globalThis.fetch = (() => {
|
|
225
|
+
probes += 1;
|
|
226
|
+
return Promise.resolve(new Response(null, { status: 404 }));
|
|
227
|
+
}) as unknown as typeof fetch;
|
|
228
|
+
const el = videoEl(false);
|
|
229
|
+
const detach = attachHlsSource(el, { streamUrl: REMUX });
|
|
230
|
+
|
|
231
|
+
el.dispatchEvent(new Event('error'));
|
|
232
|
+
await settle();
|
|
233
|
+
|
|
234
|
+
expect(probes).toBe(0);
|
|
235
|
+
detach();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('a detached source reports nothing — the listener comes off with the engine', async () => {
|
|
239
|
+
globalThis.fetch = (() =>
|
|
240
|
+
Promise.resolve(new Response(null, { status: 404 }))) as unknown as typeof fetch;
|
|
241
|
+
const faults: unknown[] = [];
|
|
242
|
+
const el = videoEl(false);
|
|
243
|
+
const detach = attachHlsSource(
|
|
244
|
+
el,
|
|
245
|
+
{ streamUrl: REMUX },
|
|
246
|
+
{ onFault: (fault) => faults.push(fault) },
|
|
247
|
+
);
|
|
248
|
+
detach();
|
|
249
|
+
|
|
250
|
+
el.dispatchEvent(new Event('error'));
|
|
251
|
+
await settle();
|
|
252
|
+
|
|
253
|
+
expect(faults).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
});
|