@clappr/hlsjs-playback 2.0.0 → 3.0.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/src/hls.test.js CHANGED
@@ -1,32 +1,80 @@
1
- import { Core, Events } from '@clappr/core'
1
+ import { Core, Events, Log, Playback } from '@clappr/core'
2
2
  import HlsjsPlayback from './hls.js'
3
3
  import HLSJS from 'hls.js'
4
4
 
5
- const simplePlaybackMock = new HlsjsPlayback({ src: 'http://clappr.io/video.m3u8' })
5
+ const DEFAULT_SRC = 'http://clappr.io/video.m3u8'
6
+
7
+ const createPlayback = (options = {}, playerError) =>
8
+ new HlsjsPlayback({ src: DEFAULT_SRC, mute: true, ...options }, null, playerError)
9
+
10
+ const makeLevelData = ({
11
+ targetduration = 6,
12
+ type = null,
13
+ totalduration = 120,
14
+ fragmentStart = 0,
15
+ rawProgramDateTime = 1556663040000,
16
+ fragments
17
+ } = {}) => ({
18
+ details: {
19
+ targetduration,
20
+ type,
21
+ totalduration,
22
+ fragments: fragments ?? [{ start: fragmentStart, rawProgramDateTime }]
23
+ }
24
+ })
25
+
26
+ const stubHls = playback => {
27
+ playback._hls = {
28
+ startLoad: jest.fn(),
29
+ recoverMediaError: jest.fn(),
30
+ swapAudioCodec: jest.fn(),
31
+ destroy: jest.fn()
32
+ }
33
+ return playback._hls
34
+ }
35
+
36
+ const freezeNow = (playback, initialMs = 1000000) => {
37
+ let current = initialMs
38
+ jest.spyOn(playback, '_now', 'get').mockImplementation(() => current)
39
+ return {
40
+ advance(ms) {
41
+ current += ms
42
+ }
43
+ }
44
+ }
6
45
 
7
46
  describe('HlsjsPlayback', () => {
47
+ beforeEach(() => {
48
+ jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(() => {})
49
+ jest.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => {})
50
+ })
51
+
52
+ afterEach(() => {
53
+ jest.restoreAllMocks()
54
+ })
55
+
8
56
  test('have a getter called defaultOptions', () => {
57
+ const playback = createPlayback()
9
58
  expect(
10
- Object.getOwnPropertyDescriptor(Object.getPrototypeOf(simplePlaybackMock), 'defaultOptions')
11
- .get
59
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(playback), 'defaultOptions').get
12
60
  ).toBeTruthy()
13
61
  })
14
62
 
15
63
  test('defaultOptions getter returns all the default options values into one object', () => {
16
- expect(simplePlaybackMock.defaultOptions).toEqual({ preload: true })
64
+ const playback = createPlayback()
65
+ expect(playback.defaultOptions).toEqual({ preload: true })
17
66
  })
18
67
 
19
68
  test('have a getter called customListeners', () => {
69
+ const playback = createPlayback()
20
70
  expect(
21
- Object.getOwnPropertyDescriptor(Object.getPrototypeOf(simplePlaybackMock), 'customListeners')
22
- .get
71
+ Object.getOwnPropertyDescriptor(Object.getPrototypeOf(playback), 'customListeners').get
23
72
  ).toBeTruthy()
24
73
  })
25
74
 
26
75
  test('customListeners getter returns all configured custom listeners for each hls.js event', () => {
27
76
  const cb = () => {}
28
- const playback = new HlsjsPlayback({
29
- src: 'http://clappr.io/foo.m3u8',
77
+ const playback = createPlayback({
30
78
  hlsPlayback: {
31
79
  customListeners: [{ eventName: 'hlsMediaAttaching', callback: cb }]
32
80
  }
@@ -80,7 +128,6 @@ describe('HlsjsPlayback', () => {
80
128
  })
81
129
 
82
130
  test('should trigger a playback error if source load failed', () => {
83
- jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(() => {})
84
131
  let resolveFn
85
132
  const promise = new Promise(resolve => {
86
133
  resolveFn = resolve
@@ -113,8 +160,7 @@ describe('HlsjsPlayback', () => {
113
160
  })
114
161
 
115
162
  test('levels supports specifying the level', () => {
116
- const options = { src: 'http://clappr.io/foo.m3u8' }
117
- const playback = new HlsjsPlayback(options)
163
+ const playback = createPlayback({ src: 'http://clappr.io/foo.m3u8' })
118
164
  playback._setup()
119
165
  // NOTE: rather than trying to call playback.setupHls, we'll punch a new one in place
120
166
  playback._hls = { levels: [] }
@@ -408,4 +454,726 @@ describe('HlsjsPlayback', () => {
408
454
  expect(playback.currentTimestamp).toBe(null)
409
455
  })
410
456
  })
457
+
458
+ describe('DVR time model', () => {
459
+ test('without correlation duration equals playable region duration', () => {
460
+ const playback = createPlayback()
461
+ playback._playbackType = Playback.LIVE
462
+ playback._playlistType = null
463
+ playback._playableRegionStartTime = 10
464
+ playback._playableRegionDuration = 100
465
+ playback._segmentTargetDuration = 6
466
+
467
+ expect(playback.getStartTimeOffset()).toBe(10)
468
+ expect(playback.getDuration()).toBe(100)
469
+ })
470
+
471
+ test('extrapolated start advances with wall clock and caps at window end', () => {
472
+ const playback = createPlayback()
473
+ playback._playbackType = Playback.LIVE
474
+ playback._playlistType = null
475
+ playback._playableRegionStartTime = 0
476
+ playback._playableRegionDuration = 120
477
+ playback._segmentTargetDuration = 6
478
+ playback._extrapolatedWindowNumSegments = 2
479
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
480
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 120000 }
481
+
482
+ const clock = freezeNow(playback, 1000000)
483
+ expect(playback.getStartTimeOffset()).toBe(0)
484
+
485
+ clock.advance(5000)
486
+ expect(playback.getStartTimeOffset()).toBe(5)
487
+
488
+ clock.advance(20000)
489
+ expect(playback.getStartTimeOffset()).toBe(12)
490
+ })
491
+
492
+ test('extrapolated end advances and is clamped to actual end', () => {
493
+ const playback = createPlayback()
494
+ playback._playbackType = Playback.LIVE
495
+ playback._playlistType = null
496
+ playback._playableRegionStartTime = 0
497
+ playback._playableRegionDuration = 100
498
+ playback._segmentTargetDuration = 6
499
+ playback._extrapolatedWindowNumSegments = 2
500
+ playback._localStartTimeCorrelation = null
501
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 88000 }
502
+
503
+ const clock = freezeNow(playback, 1000000)
504
+ expect(playback.getDuration()).toBe(88)
505
+
506
+ clock.advance(5000)
507
+ expect(playback.getDuration()).toBe(93)
508
+
509
+ clock.advance(20000)
510
+ expect(playback.getDuration()).toBe(100)
511
+ })
512
+
513
+ test('EVENT playlist uses raw playable start instead of extrapolated start', () => {
514
+ const playback = createPlayback()
515
+ playback._playbackType = Playback.LIVE
516
+ playback._playlistType = 'EVENT'
517
+ playback._playableRegionStartTime = 40
518
+ playback._playableRegionDuration = 80
519
+ playback._segmentTargetDuration = 6
520
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
521
+ freezeNow(playback, 1010000)
522
+
523
+ expect(playback.getStartTimeOffset()).toBe(40)
524
+ expect(playback.getDuration()).toBe(80)
525
+ })
526
+
527
+ test('VOD uses raw playable start instead of extrapolated start', () => {
528
+ const playback = createPlayback()
529
+ playback._playbackType = Playback.VOD
530
+ playback._playableRegionStartTime = 5
531
+ playback._playableRegionDuration = 60
532
+ playback._segmentTargetDuration = 6
533
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
534
+ freezeNow(playback, 1010000)
535
+
536
+ expect(playback.getStartTimeOffset()).toBe(5)
537
+ expect(playback.getDuration()).toBe(60)
538
+ })
539
+
540
+ test('extrapolated window duration is zero when segment target duration is null', () => {
541
+ const playback = createPlayback()
542
+ playback._playbackType = Playback.LIVE
543
+ playback._playlistType = null
544
+ playback._playableRegionStartTime = 0
545
+ playback._playableRegionDuration = 100
546
+ playback._segmentTargetDuration = null
547
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
548
+ const clock = freezeNow(playback, 1000000)
549
+
550
+ clock.advance(5000)
551
+ expect(playback.getStartTimeOffset()).toBe(0)
552
+ })
553
+ })
554
+
555
+ describe('_onLevelUpdated', () => {
556
+ test('returns early when fragments are empty without mutating start time', () => {
557
+ const playback = createPlayback()
558
+ playback._playableRegionStartTime = 15
559
+ playback._onLevelUpdated('levelUpdated', makeLevelData({ fragments: [] }))
560
+
561
+ expect(playback._playableRegionStartTime).toBe(15)
562
+ })
563
+
564
+ test('first update sets program date time, duration, and correlations', () => {
565
+ const playback = createPlayback()
566
+ playback._playbackType = Playback.LIVE
567
+ freezeNow(playback, 2000000)
568
+
569
+ playback._onLevelUpdated(
570
+ 'levelUpdated',
571
+ makeLevelData({
572
+ targetduration: 6,
573
+ totalduration: 120,
574
+ fragmentStart: 10,
575
+ rawProgramDateTime: 1556663040000
576
+ })
577
+ )
578
+
579
+ expect(playback.getProgramDateTime()).toBe(1556663040000)
580
+ expect(playback._playableRegionDuration).toBe(102)
581
+ expect(playback._durationExcludesAfterLiveSyncPoint).toBe(true)
582
+ expect(playback._playableRegionStartTime).toBe(10)
583
+ expect(playback._localStartTimeCorrelation).toEqual({
584
+ local: 2000000,
585
+ remote: (10 + 6) * 1000
586
+ })
587
+ expect(playback._localEndTimeCorrelation).toEqual({
588
+ local: 2000000,
589
+ remote: 112000
590
+ })
591
+ })
592
+
593
+ test('does not trim duration when live sync hidden area exceeds total duration', () => {
594
+ const playback = createPlayback()
595
+ playback._playbackType = Playback.LIVE
596
+ freezeNow(playback)
597
+
598
+ playback._onLevelUpdated(
599
+ 'levelUpdated',
600
+ makeLevelData({ targetduration: 10, totalduration: 20, fragmentStart: 0 })
601
+ )
602
+
603
+ expect(playback._playableRegionDuration).toBe(20)
604
+ expect(playback._durationExcludesAfterLiveSyncPoint).toBe(false)
605
+ })
606
+
607
+ test('VOD update does not apply live sync trim', () => {
608
+ const playback = createPlayback()
609
+ playback._playbackType = Playback.VOD
610
+ freezeNow(playback)
611
+
612
+ playback._onLevelUpdated(
613
+ 'levelUpdated',
614
+ makeLevelData({ targetduration: 6, totalduration: 120, fragmentStart: 0 })
615
+ )
616
+
617
+ expect(playback._playableRegionDuration).toBe(120)
618
+ expect(playback._durationExcludesAfterLiveSyncPoint).toBe(false)
619
+ })
620
+
621
+ test('resets start correlation when extrapolated start falls before first chunk', () => {
622
+ const playback = createPlayback()
623
+ playback._playbackType = Playback.LIVE
624
+ playback._segmentTargetDuration = 6
625
+ playback._playableRegionStartTime = 0
626
+ playback._playableRegionDuration = 102
627
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
628
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 102000 }
629
+ freezeNow(playback, 1000000)
630
+
631
+ playback._onLevelUpdated(
632
+ 'levelUpdated',
633
+ makeLevelData({
634
+ targetduration: 6,
635
+ totalduration: 120,
636
+ fragmentStart: 30,
637
+ rawProgramDateTime: null
638
+ })
639
+ )
640
+
641
+ expect(playback._localStartTimeCorrelation.remote).toBe(30000)
642
+ })
643
+
644
+ test('resets start correlation when extrapolated start was past the old window cap', () => {
645
+ const playback = createPlayback()
646
+ playback._playbackType = Playback.LIVE
647
+ playback._segmentTargetDuration = 6
648
+ playback._extrapolatedWindowNumSegments = 2
649
+ playback._playableRegionStartTime = 0
650
+ playback._playableRegionDuration = 102
651
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
652
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 102000 }
653
+ freezeNow(playback, 1020000)
654
+
655
+ playback._onLevelUpdated(
656
+ 'levelUpdated',
657
+ makeLevelData({
658
+ targetduration: 6,
659
+ totalduration: 120,
660
+ fragmentStart: 5,
661
+ rawProgramDateTime: null
662
+ })
663
+ )
664
+
665
+ expect(playback._localStartTimeCorrelation.remote).toBe(12000)
666
+ })
667
+
668
+ test('resets end correlation when extrapolated end exceeds new end time', () => {
669
+ const playback = createPlayback()
670
+ playback._playbackType = Playback.LIVE
671
+ playback._segmentTargetDuration = 6
672
+ playback._playableRegionStartTime = 0
673
+ playback._playableRegionDuration = 200
674
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
675
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 200000 }
676
+ freezeNow(playback, 1000000)
677
+
678
+ playback._onLevelUpdated(
679
+ 'levelUpdated',
680
+ makeLevelData({
681
+ targetduration: 6,
682
+ totalduration: 60,
683
+ fragmentStart: 0,
684
+ rawProgramDateTime: null
685
+ })
686
+ )
687
+
688
+ expect(playback._localEndTimeCorrelation.remote).toBe(42000)
689
+ })
690
+
691
+ test('resets end correlation when extrapolated end was capped past the previous end', () => {
692
+ const playback = createPlayback()
693
+ playback._playbackType = Playback.LIVE
694
+ playback._segmentTargetDuration = 6
695
+ playback._extrapolatedWindowNumSegments = 2
696
+ playback._playableRegionStartTime = 0
697
+ playback._playableRegionDuration = 100
698
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
699
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 100000 }
700
+ freezeNow(playback, 1005000)
701
+
702
+ playback._onLevelUpdated(
703
+ 'levelUpdated',
704
+ makeLevelData({
705
+ targetduration: 6,
706
+ totalduration: 128,
707
+ fragmentStart: 0,
708
+ rawProgramDateTime: null
709
+ })
710
+ )
711
+
712
+ expect(playback._localEndTimeCorrelation.remote).toBe(100000)
713
+ })
714
+
715
+ test('resets end correlation when extrapolated end falls behind the window from the end', () => {
716
+ const playback = createPlayback()
717
+ playback._playbackType = Playback.LIVE
718
+ playback._segmentTargetDuration = 6
719
+ playback._extrapolatedWindowNumSegments = 2
720
+ playback._playableRegionStartTime = 0
721
+ playback._playableRegionDuration = 100
722
+ playback._localStartTimeCorrelation = { local: 1000000, remote: 0 }
723
+ playback._localEndTimeCorrelation = { local: 1000000, remote: 100000 }
724
+ freezeNow(playback, 1000000)
725
+
726
+ playback._onLevelUpdated(
727
+ 'levelUpdated',
728
+ makeLevelData({
729
+ targetduration: 6,
730
+ totalduration: 200,
731
+ fragmentStart: 0,
732
+ rawProgramDateTime: null
733
+ })
734
+ )
735
+
736
+ expect(playback._localEndTimeCorrelation.remote).toBe((182 - 12) * 1000)
737
+ })
738
+
739
+ test('calls duration and progress handlers when start and duration change', () => {
740
+ const playback = createPlayback()
741
+ playback._playbackType = Playback.LIVE
742
+ freezeNow(playback)
743
+ jest.spyOn(playback, '_onDurationChange').mockImplementation(() => {})
744
+ jest.spyOn(playback, '_onProgress').mockImplementation(() => {})
745
+
746
+ playback._onLevelUpdated(
747
+ 'levelUpdated',
748
+ makeLevelData({ targetduration: 6, totalduration: 120, fragmentStart: 10 })
749
+ )
750
+
751
+ expect(playback._onDurationChange).toHaveBeenCalled()
752
+ expect(playback._onProgress).toHaveBeenCalled()
753
+ })
754
+
755
+ test('respects custom liveSyncDurationCount from hlsjsConfig', () => {
756
+ const playback = createPlayback({
757
+ playback: { hlsjsConfig: { liveSyncDurationCount: 2 } }
758
+ })
759
+ playback._playbackType = Playback.LIVE
760
+ freezeNow(playback)
761
+
762
+ playback._onLevelUpdated(
763
+ 'levelUpdated',
764
+ makeLevelData({ targetduration: 10, totalduration: 100, fragmentStart: 0 })
765
+ )
766
+
767
+ expect(playback._playableRegionDuration).toBe(80)
768
+ expect(playback._durationExcludesAfterLiveSyncPoint).toBe(true)
769
+ })
770
+ })
771
+
772
+ describe('time API', () => {
773
+ test('getCurrentTime offsets by start time and never goes below zero', () => {
774
+ const playback = createPlayback()
775
+ playback._playbackType = Playback.VOD
776
+ playback._playableRegionStartTime = 20
777
+ playback._playableRegionDuration = 100
778
+ playback.el.currentTime = 45
779
+
780
+ expect(playback.getCurrentTime()).toBe(25)
781
+
782
+ playback.el.currentTime = 10
783
+ expect(playback.getCurrentTime()).toBe(0)
784
+ })
785
+
786
+ test('seek writes element currentTime including start offset', () => {
787
+ const playback = createPlayback()
788
+ playback._playbackType = Playback.VOD
789
+ playback._playableRegionStartTime = 20
790
+ playback._playableRegionDuration = 100
791
+
792
+ playback.seek(30)
793
+ expect(playback.el.currentTime).toBe(50)
794
+ })
795
+
796
+ test('seek of negative time warns and seeks to duration', () => {
797
+ const playback = createPlayback()
798
+ playback._playbackType = Playback.VOD
799
+ playback._playableRegionStartTime = 0
800
+ playback._playableRegionDuration = 80
801
+ jest.spyOn(Log, 'warn').mockImplementation(() => {})
802
+
803
+ playback.seek(-1)
804
+
805
+ expect(Log.warn).toHaveBeenCalled()
806
+ expect(playback.el.currentTime).toBe(80)
807
+ })
808
+
809
+ test('seekPercentage maps percent of duration then seeks', () => {
810
+ const playback = createPlayback()
811
+ playback._playbackType = Playback.VOD
812
+ playback._playableRegionStartTime = 10
813
+ playback._playableRegionDuration = 100
814
+
815
+ playback.seekPercentage(50)
816
+ expect(playback.el.currentTime).toBe(60)
817
+ })
818
+
819
+ test('seekPercentage of zero seeks to the start', () => {
820
+ const playback = createPlayback()
821
+ playback._playbackType = Playback.VOD
822
+ playback._playableRegionStartTime = 10
823
+ playback._playableRegionDuration = 90
824
+ playback.el.currentTime = 50
825
+
826
+ playback.seekPercentage(0)
827
+ expect(playback.el.currentTime).toBe(10)
828
+ })
829
+
830
+ test('seekToLivePoint seeks to duration', () => {
831
+ const playback = createPlayback()
832
+ playback._playbackType = Playback.LIVE
833
+ playback._playlistType = 'EVENT'
834
+ playback._playableRegionStartTime = 5
835
+ playback._playableRegionDuration = 70
836
+
837
+ playback.seekToLivePoint()
838
+ expect(playback.el.currentTime).toBe(75)
839
+ })
840
+ })
841
+
842
+ describe('_onTimeUpdate throttling', () => {
843
+ test('first time update fires PLAYBACK_TIMEUPDATE', () => {
844
+ const playback = createPlayback()
845
+ playback._playbackType = Playback.VOD
846
+ playback._playableRegionDuration = 100
847
+ playback.el.currentTime = 1
848
+ freezeNow(playback, 1000000)
849
+ const onTimeUpdate = jest.fn()
850
+ playback.on(Events.PLAYBACK_TIMEUPDATE, onTimeUpdate)
851
+
852
+ playback._onTimeUpdate()
853
+
854
+ expect(onTimeUpdate).toHaveBeenCalledTimes(1)
855
+ expect(onTimeUpdate.mock.calls[0][0]).toMatchObject({ current: 1, total: 100 })
856
+ })
857
+
858
+ test('identical payload within throttle delay is suppressed', () => {
859
+ const playback = createPlayback()
860
+ playback._playbackType = Playback.VOD
861
+ playback._playableRegionDuration = 100
862
+ playback.el.currentTime = 1
863
+ const clock = freezeNow(playback, 1000000)
864
+ const onTimeUpdate = jest.fn()
865
+ playback.on(Events.PLAYBACK_TIMEUPDATE, onTimeUpdate)
866
+
867
+ playback._onTimeUpdate()
868
+ clock.advance(50)
869
+ playback._onTimeUpdate()
870
+
871
+ expect(onTimeUpdate).toHaveBeenCalledTimes(1)
872
+ })
873
+
874
+ test('fires again after throttle delay even with same payload', () => {
875
+ const playback = createPlayback()
876
+ playback._playbackType = Playback.VOD
877
+ playback._playableRegionDuration = 100
878
+ playback.el.currentTime = 1
879
+ const clock = freezeNow(playback, 1000000)
880
+ const onTimeUpdate = jest.fn()
881
+ playback.on(Events.PLAYBACK_TIMEUPDATE, onTimeUpdate)
882
+
883
+ playback._onTimeUpdate()
884
+ clock.advance(250)
885
+ playback._onTimeUpdate()
886
+
887
+ expect(onTimeUpdate).toHaveBeenCalledTimes(2)
888
+ })
889
+
890
+ test('changed current time fires despite throttle window', () => {
891
+ const playback = createPlayback()
892
+ playback._playbackType = Playback.VOD
893
+ playback._playableRegionDuration = 100
894
+ playback.el.currentTime = 1
895
+ const clock = freezeNow(playback, 1000000)
896
+ const onTimeUpdate = jest.fn()
897
+ playback.on(Events.PLAYBACK_TIMEUPDATE, onTimeUpdate)
898
+
899
+ playback._onTimeUpdate()
900
+ clock.advance(50)
901
+ playback.el.currentTime = 2
902
+ playback._onTimeUpdate()
903
+
904
+ expect(onTimeUpdate).toHaveBeenCalledTimes(2)
905
+ })
906
+
907
+ test('changed program date time fires despite throttle window', () => {
908
+ const playback = createPlayback()
909
+ playback._playbackType = Playback.VOD
910
+ playback._playableRegionDuration = 100
911
+ playback._programDateTime = 1000
912
+ playback.el.currentTime = 1
913
+ const clock = freezeNow(playback, 1000000)
914
+ const onTimeUpdate = jest.fn()
915
+ playback.on(Events.PLAYBACK_TIMEUPDATE, onTimeUpdate)
916
+
917
+ playback._onTimeUpdate()
918
+ clock.advance(50)
919
+ playback._programDateTime = 2000
920
+ playback._onTimeUpdate()
921
+
922
+ expect(onTimeUpdate).toHaveBeenCalledTimes(2)
923
+ })
924
+ })
925
+
926
+ describe('_onHLSJSError', () => {
927
+ const cores = []
928
+
929
+ afterEach(() => {
930
+ cores.splice(0).forEach(core => core.destroy())
931
+ })
932
+
933
+ const createErrorPlayback = (options = {}) => {
934
+ const core = new Core({})
935
+ cores.push(core)
936
+ const playback = createPlayback(options, core.playerError)
937
+ stubHls(playback)
938
+ jest.spyOn(playback, 'play').mockImplementation(() => {})
939
+ jest.spyOn(playback, 'stop').mockImplementation(() => {})
940
+ return playback
941
+ }
942
+
943
+ test('recoverable network fatal error calls startLoad', () => {
944
+ const playback = createErrorPlayback()
945
+ jest.spyOn(Log, 'warn').mockImplementation(() => {})
946
+
947
+ playback._onHLSJSError('hlsError', {
948
+ fatal: true,
949
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
950
+ details: HLSJS.ErrorDetails.FRAG_LOAD_ERROR
951
+ })
952
+
953
+ expect(playback._hls.startLoad).toHaveBeenCalled()
954
+ expect(playback.stop).not.toHaveBeenCalled()
955
+ expect(playback._recoverAttemptsRemaining).toBe(15)
956
+ })
957
+
958
+ test.each([
959
+ HLSJS.ErrorDetails.MANIFEST_LOAD_ERROR,
960
+ HLSJS.ErrorDetails.MANIFEST_LOAD_TIMEOUT,
961
+ HLSJS.ErrorDetails.MANIFEST_PARSING_ERROR,
962
+ HLSJS.ErrorDetails.LEVEL_LOAD_ERROR,
963
+ HLSJS.ErrorDetails.LEVEL_LOAD_TIMEOUT
964
+ ])('unrecoverable network fatal %s triggers error and stop', details => {
965
+ const playback = createErrorPlayback()
966
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
967
+ const onError = jest.fn()
968
+ playback.on(Events.PLAYBACK_ERROR, onError)
969
+
970
+ playback._onHLSJSError('hlsError', {
971
+ fatal: true,
972
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
973
+ details
974
+ })
975
+
976
+ expect(onError).toHaveBeenCalledTimes(1)
977
+ expect(playback.stop).toHaveBeenCalled()
978
+ expect(playback._hls.startLoad).not.toHaveBeenCalled()
979
+ })
980
+
981
+ test('media fatal error recovers via recoverMediaError then swapAudioCodec then fails', () => {
982
+ const playback = createErrorPlayback()
983
+ jest.spyOn(Log, 'warn').mockImplementation(() => {})
984
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
985
+ const onError = jest.fn()
986
+ playback.on(Events.PLAYBACK_ERROR, onError)
987
+ const mediaError = {
988
+ fatal: true,
989
+ type: HLSJS.ErrorTypes.MEDIA_ERROR,
990
+ details: HLSJS.ErrorDetails.BUFFER_STALLED_ERROR
991
+ }
992
+
993
+ playback._onHLSJSError('hlsError', mediaError)
994
+ expect(playback._hls.recoverMediaError).toHaveBeenCalledTimes(1)
995
+ expect(playback._hls.swapAudioCodec).not.toHaveBeenCalled()
996
+ expect(playback.play).toHaveBeenCalledTimes(1)
997
+
998
+ playback._onHLSJSError('hlsError', mediaError)
999
+ expect(playback._hls.swapAudioCodec).toHaveBeenCalledTimes(1)
1000
+ expect(playback._hls.recoverMediaError).toHaveBeenCalledTimes(2)
1001
+ expect(playback.play).toHaveBeenCalledTimes(2)
1002
+ expect(onError).not.toHaveBeenCalled()
1003
+
1004
+ playback._onHLSJSError('hlsError', mediaError)
1005
+ expect(onError).toHaveBeenCalledTimes(1)
1006
+ expect(playback.stop).toHaveBeenCalled()
1007
+ })
1008
+
1009
+ test('other fatal error types trigger error and stop', () => {
1010
+ const playback = createErrorPlayback()
1011
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
1012
+ const onError = jest.fn()
1013
+ playback.on(Events.PLAYBACK_ERROR, onError)
1014
+
1015
+ playback._onHLSJSError('hlsError', {
1016
+ fatal: true,
1017
+ type: HLSJS.ErrorTypes.OTHER_ERROR,
1018
+ details: 'whatever'
1019
+ })
1020
+
1021
+ expect(onError).toHaveBeenCalledTimes(1)
1022
+ expect(playback.stop).toHaveBeenCalled()
1023
+ })
1024
+
1025
+ test('exhausted recover attempts trigger error and stop', () => {
1026
+ const playback = createErrorPlayback({ hlsRecoverAttempts: 0 })
1027
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
1028
+ const onError = jest.fn()
1029
+ playback.on(Events.PLAYBACK_ERROR, onError)
1030
+
1031
+ playback._onHLSJSError('hlsError', {
1032
+ fatal: true,
1033
+ type: HLSJS.ErrorTypes.MEDIA_ERROR,
1034
+ details: HLSJS.ErrorDetails.BUFFER_STALLED_ERROR
1035
+ })
1036
+
1037
+ expect(onError).toHaveBeenCalledTimes(1)
1038
+ expect(playback.stop).toHaveBeenCalled()
1039
+ expect(playback._hls.recoverMediaError).not.toHaveBeenCalled()
1040
+ })
1041
+
1042
+ test('non-fatal key denial with option triggers fatal error and stop', () => {
1043
+ const playback = createErrorPlayback({
1044
+ playback: { triggerFatalErrorOnResourceDenied: true }
1045
+ })
1046
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
1047
+ const onError = jest.fn()
1048
+ playback.on(Events.PLAYBACK_ERROR, onError)
1049
+
1050
+ playback._onHLSJSError('hlsError', {
1051
+ fatal: false,
1052
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
1053
+ details: HLSJS.ErrorDetails.KEY_LOAD_ERROR,
1054
+ response: { code: 403 }
1055
+ })
1056
+
1057
+ expect(onError).toHaveBeenCalledTimes(1)
1058
+ expect(playback.stop).toHaveBeenCalled()
1059
+ })
1060
+
1061
+ test('non-fatal key denial without option only warns', () => {
1062
+ const playback = createErrorPlayback()
1063
+ jest.spyOn(Log, 'warn').mockImplementation(() => {})
1064
+ const onError = jest.fn()
1065
+ playback.on(Events.PLAYBACK_ERROR, onError)
1066
+
1067
+ playback._onHLSJSError('hlsError', {
1068
+ fatal: false,
1069
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
1070
+ details: HLSJS.ErrorDetails.KEY_LOAD_ERROR,
1071
+ response: { code: 403 }
1072
+ })
1073
+
1074
+ expect(onError).not.toHaveBeenCalled()
1075
+ expect(playback.stop).not.toHaveBeenCalled()
1076
+ expect(Log.warn).toHaveBeenCalled()
1077
+ })
1078
+
1079
+ test('non-fatal key load with response below 400 only warns', () => {
1080
+ const playback = createErrorPlayback({
1081
+ playback: { triggerFatalErrorOnResourceDenied: true }
1082
+ })
1083
+ jest.spyOn(Log, 'warn').mockImplementation(() => {})
1084
+ const onError = jest.fn()
1085
+ playback.on(Events.PLAYBACK_ERROR, onError)
1086
+
1087
+ playback._onHLSJSError('hlsError', {
1088
+ fatal: false,
1089
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
1090
+ details: HLSJS.ErrorDetails.KEY_LOAD_ERROR,
1091
+ response: { code: 200 }
1092
+ })
1093
+
1094
+ expect(onError).not.toHaveBeenCalled()
1095
+ expect(Log.warn).toHaveBeenCalled()
1096
+ })
1097
+
1098
+ test('includes response in error description when present', () => {
1099
+ const playback = createErrorPlayback()
1100
+ jest.spyOn(Log, 'error').mockImplementation(() => {})
1101
+ const onError = jest.fn()
1102
+ playback.on(Events.PLAYBACK_ERROR, onError)
1103
+
1104
+ playback._onHLSJSError('hlsError', {
1105
+ fatal: true,
1106
+ type: HLSJS.ErrorTypes.NETWORK_ERROR,
1107
+ details: HLSJS.ErrorDetails.MANIFEST_LOAD_ERROR,
1108
+ response: { code: 404, text: 'missing' }
1109
+ })
1110
+
1111
+ expect(onError.mock.calls[0][0].description).toContain('response:')
1112
+ })
1113
+ })
1114
+
1115
+ describe('dvrEnabled and settings', () => {
1116
+ test('dvrEnabled requires live sync trim, min duration, and LIVE type', () => {
1117
+ const playback = createPlayback({ hlsMinimumDvrSize: 60 })
1118
+ playback._playbackType = Playback.LIVE
1119
+ playback._playlistType = 'EVENT'
1120
+ playback._playableRegionStartTime = 0
1121
+ playback._playableRegionDuration = 120
1122
+ playback._durationExcludesAfterLiveSyncPoint = true
1123
+
1124
+ expect(playback.dvrEnabled).toBe(true)
1125
+
1126
+ playback._durationExcludesAfterLiveSyncPoint = false
1127
+ expect(playback.dvrEnabled).toBe(false)
1128
+
1129
+ playback._durationExcludesAfterLiveSyncPoint = true
1130
+ playback._playableRegionDuration = 30
1131
+ expect(playback.dvrEnabled).toBe(false)
1132
+
1133
+ playback._playableRegionDuration = 120
1134
+ playback._playbackType = Playback.VOD
1135
+ expect(playback.dvrEnabled).toBe(false)
1136
+ })
1137
+
1138
+ test('isSeekEnabled is true for VOD or when dvr is enabled', () => {
1139
+ const playback = createPlayback({ hlsMinimumDvrSize: 60 })
1140
+ playback._playbackType = Playback.VOD
1141
+ expect(playback.isSeekEnabled()).toBe(true)
1142
+
1143
+ playback._playbackType = Playback.LIVE
1144
+ playback._playlistType = 'EVENT'
1145
+ playback._playableRegionDuration = 120
1146
+ playback._durationExcludesAfterLiveSyncPoint = false
1147
+ expect(playback.isSeekEnabled()).toBe(false)
1148
+
1149
+ playback._durationExcludesAfterLiveSyncPoint = true
1150
+ expect(playback.isSeekEnabled()).toBe(true)
1151
+ })
1152
+
1153
+ test('_updateSettings chooses left controls from playback type and dvr', () => {
1154
+ const playback = createPlayback({ hlsMinimumDvrSize: 60 })
1155
+ const onSettings = jest.fn()
1156
+ playback.on(Events.PLAYBACK_SETTINGSUPDATE, onSettings)
1157
+
1158
+ playback._playbackType = Playback.VOD
1159
+ playback._updateSettings()
1160
+ expect(playback.settings.left).toEqual(['playpause', 'position', 'duration'])
1161
+ expect(playback.settings.seekEnabled).toBe(true)
1162
+
1163
+ playback._playbackType = Playback.LIVE
1164
+ playback._playlistType = 'EVENT'
1165
+ playback._playableRegionDuration = 120
1166
+ playback._durationExcludesAfterLiveSyncPoint = true
1167
+ playback._updateSettings()
1168
+ expect(playback.settings.left).toEqual(['playpause'])
1169
+ expect(playback.settings.seekEnabled).toBe(true)
1170
+
1171
+ playback._durationExcludesAfterLiveSyncPoint = false
1172
+ playback._updateSettings()
1173
+ expect(playback.settings.left).toEqual(['playstop'])
1174
+ expect(playback.settings.seekEnabled).toBe(false)
1175
+
1176
+ expect(onSettings).toHaveBeenCalledTimes(3)
1177
+ })
1178
+ })
411
1179
  })