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.
Files changed (95) hide show
  1. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  2. package/dist/react/master-lock/MasterLockDialog.js +25 -5
  3. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  4. package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
  5. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  6. package/dist/react/master-lock/MasterLockProvider.js +19 -0
  7. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  8. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  9. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  10. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  11. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  12. package/dist/react/media/FrameGrabber.d.ts +16 -2
  13. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  14. package/dist/react/media/FrameGrabber.js +68 -43
  15. package/dist/react/media/FrameGrabber.js.map +1 -1
  16. package/dist/react/media/PictureEditor.d.ts +17 -2
  17. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  18. package/dist/react/media/PictureEditor.js +33 -7
  19. package/dist/react/media/PictureEditor.js.map +1 -1
  20. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  21. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  22. package/dist/react/media/VideoChapterEditor.js +21 -3
  23. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  24. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  25. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  26. package/dist/react/media/VideoCutEditor.js +21 -3
  27. package/dist/react/media/VideoCutEditor.js.map +1 -1
  28. package/dist/react/media/VideoPlayer.d.ts +37 -9
  29. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  30. package/dist/react/media/VideoPlayer.js +130 -39
  31. package/dist/react/media/VideoPlayer.js.map +1 -1
  32. package/dist/react/media/hlsSource.d.ts +24 -3
  33. package/dist/react/media/hlsSource.d.ts.map +1 -1
  34. package/dist/react/media/hlsSource.js +109 -8
  35. package/dist/react/media/hlsSource.js.map +1 -1
  36. package/dist/react/media/pictureEditor.css +2 -0
  37. package/dist/react/media/sourceFault.d.ts +146 -0
  38. package/dist/react/media/sourceFault.d.ts.map +1 -0
  39. package/dist/react/media/sourceFault.js +139 -0
  40. package/dist/react/media/sourceFault.js.map +1 -0
  41. package/dist/react/media/videoChapterEditor.css +19 -0
  42. package/dist/react/media/videoCutEditor.css +22 -0
  43. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  44. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  45. package/dist/react/media-gallery/MediaGallery.js +166 -13
  46. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  47. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  48. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  49. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  50. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  51. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  52. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  53. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  54. package/dist/react/media-gallery/types.d.ts +23 -0
  55. package/dist/react/media-gallery/types.d.ts.map +1 -1
  56. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  57. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  58. package/dist/styles-areas/master-lock.css +1 -1
  59. package/dist/styles-areas/media-gallery.css +1 -1
  60. package/dist/styles-areas/media.css +1 -1
  61. package/package.json +3 -2
  62. package/scripts/guardrailsEnforce.spec.ts +244 -1
  63. package/scripts/guardrailsEnforce.ts +224 -7
  64. package/src/iframesMakeNoRealRequest.spec.ts +92 -0
  65. package/src/react/components/IsolatedFrame.spec.tsx +27 -9
  66. package/src/react/master-lock/MasterLockDialog.tsx +24 -5
  67. package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
  68. package/src/react/master-lock/MasterLockProvider.tsx +19 -0
  69. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  70. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  71. package/src/react/media/FrameGrabber.tsx +138 -84
  72. package/src/react/media/PictureEditor.spec.tsx +90 -0
  73. package/src/react/media/PictureEditor.tsx +78 -23
  74. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  75. package/src/react/media/VideoChapterEditor.tsx +64 -18
  76. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  77. package/src/react/media/VideoCutEditor.tsx +63 -18
  78. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  79. package/src/react/media/VideoPlayer.tsx +221 -73
  80. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  81. package/src/react/media/hlsSource.ts +136 -8
  82. package/src/react/media/pictureEditor.css +2 -0
  83. package/src/react/media/sourceFault.spec.tsx +224 -0
  84. package/src/react/media/sourceFault.tsx +220 -0
  85. package/src/react/media/videoChapterEditor.css +19 -0
  86. package/src/react/media/videoCutEditor.css +22 -0
  87. package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
  88. package/src/react/media-gallery/MediaGallery.tsx +356 -45
  89. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  90. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  91. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  92. package/src/react/media-gallery/types.ts +23 -0
  93. package/src/styles-areas/master-lock.css +1 -1
  94. package/src/styles-areas/media-gallery.css +1 -1
  95. 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 — and its named sections, when the app carries
53
- * any. {@link ChapteredVideo} is `PlayableVideo` plus `chapters`, so every existing source
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: ChapteredVideo;
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 = video.hlsUrl
528
- ? ({ src: video.hlsUrl, type: 'application/x-mpegurl' } as const)
529
- : video.streamUrl;
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
- const showProcessing = status != null && status !== 'ready';
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 status={status} progress={video.processingProgress} />
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 processing state for a video surface: a thin progress bar pinned across the bottom edge
730
- * while derivatives build (indeterminate shimmer until real progress arrives), or a compact
731
- * corner badge when optimization failed. Never covers the video — the original bytes stay
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: NonNullable<PlayableVideo['processingStatus']>;
898
+ status?: PlayableVideo['processingStatus'];
739
899
  progress?: number;
900
+ playbackDegraded?: boolean;
740
901
  }) {
741
- if (status === 'failed') {
742
- return (
743
- <div
744
- role='status'
745
- style={{
746
- position: 'absolute',
747
- top: 8,
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
- <div
772
- role='progressbar'
773
- aria-label='Optimizing video'
774
- aria-valuemin={0}
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
- width: indeterminate ? '25%' : `${clamped}%`,
800
- height: '100%',
801
- background: 'var(--vds-accent, var(--accent))',
802
- transition: indeterminate ? undefined : 'width 300ms',
803
- animation: indeterminate
804
- ? 'cb-video-processing-indeterminate 1.4s ease-in-out infinite'
805
- : undefined,
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
- </div>
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
+ });