@amazon/vinyl 1.2.1 → 1.2.2

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 (89) hide show
  1. package/dist/index.cjs +13 -4
  2. package/dist/index.cjs.map +2 -2
  3. package/dist/index.js +18 -9
  4. package/dist/index.js.map +3 -3
  5. package/dist/test/integ.es5.js +60 -60
  6. package/dist/test/integ.js +49 -4
  7. package/dist/test/integ.js.map +2 -2
  8. package/dist/types/current/ad/AdBreakInfo.d.ts.map +1 -1
  9. package/dist/types/current/ad/AdControllerImpl.d.ts +18 -2
  10. package/dist/types/current/ad/AdControllerImpl.d.ts.map +1 -1
  11. package/dist/types/current/ad/AdControllerImpl.js +58 -22
  12. package/dist/types/current/ad/discoverHlsInterstitials.d.ts.map +1 -1
  13. package/dist/types/current/ad/discoverHlsInterstitials.js +5 -3
  14. package/dist/types/current/drm/DrmControllerImpl.d.ts +6 -0
  15. package/dist/types/current/drm/DrmControllerImpl.d.ts.map +1 -1
  16. package/dist/types/current/drm/DrmControllerImpl.js +13 -1
  17. package/dist/types/current/playback/createStallDetector.d.ts +43 -0
  18. package/dist/types/current/playback/createStallDetector.d.ts.map +1 -0
  19. package/dist/types/current/playback/createStallDetector.js +101 -0
  20. package/dist/types/current/playback/createStallMonitor.d.ts +45 -0
  21. package/dist/types/current/playback/createStallMonitor.d.ts.map +1 -0
  22. package/dist/types/current/playback/createStallMonitor.js +99 -0
  23. package/dist/types/current/streaming/AudioOptions.d.ts +27 -0
  24. package/dist/types/current/streaming/AudioOptions.d.ts.map +1 -1
  25. package/dist/types/current/streaming/AudioOptions.js +5 -1
  26. package/dist/types/current/streaming/LoadMetric.d.ts +15 -1
  27. package/dist/types/current/streaming/LoadMetric.d.ts.map +1 -1
  28. package/dist/types/current/streaming/createTimelineContentTypesValue.d.ts +21 -0
  29. package/dist/types/current/streaming/createTimelineContentTypesValue.d.ts.map +1 -0
  30. package/dist/types/current/streaming/createTimelineContentTypesValue.js +25 -0
  31. package/dist/types/current/text/SidecarTextTrackLoader.d.ts.map +1 -1
  32. package/dist/types/current/text/SidecarTextTrackLoader.js +4 -4
  33. package/dist/types/current/text/TextTrack.d.ts.map +1 -1
  34. package/dist/types/current/text/discoverDashTextTracks.d.ts +2 -2
  35. package/dist/types/current/text/discoverDashTextTracks.js +4 -3
  36. package/dist/types/current/text/parseWebVtt.d.ts.map +1 -1
  37. package/dist/types/current/text/parseWebVtt.js +3 -2
  38. package/dist/types/current/track/mse/MseTrack.d.ts +0 -1
  39. package/dist/types/current/track/mse/MseTrack.d.ts.map +1 -1
  40. package/dist/types/current/track/mse/MseTrack.js +4 -5
  41. package/dist/types/current/tsconfig.tsbuildinfo +1 -1
  42. package/dist/types/ts3.7/ad/AdControllerImpl.d.ts +18 -2
  43. package/dist/types/ts3.7/drm/DrmControllerImpl.d.ts +6 -0
  44. package/dist/types/ts3.7/playback/createStallDetector.d.ts +43 -0
  45. package/dist/types/ts3.7/playback/createStallMonitor.d.ts +45 -0
  46. package/dist/types/ts3.7/streaming/AudioOptions.d.ts +27 -0
  47. package/dist/types/ts3.7/streaming/LoadMetric.d.ts +15 -1
  48. package/dist/types/ts3.7/streaming/createTimelineContentTypesValue.d.ts +21 -0
  49. package/dist/types/ts3.7/text/discoverDashTextTracks.d.ts +2 -2
  50. package/dist/types/ts3.7/track/mse/MseTrack.d.ts +0 -1
  51. package/dist/types/ts4.1/ad/AdControllerImpl.d.ts +18 -2
  52. package/dist/types/ts4.1/drm/DrmControllerImpl.d.ts +6 -0
  53. package/dist/types/ts4.1/playback/createStallDetector.d.ts +43 -0
  54. package/dist/types/ts4.1/playback/createStallMonitor.d.ts +45 -0
  55. package/dist/types/ts4.1/streaming/AudioOptions.d.ts +27 -0
  56. package/dist/types/ts4.1/streaming/LoadMetric.d.ts +15 -1
  57. package/dist/types/ts4.1/streaming/createTimelineContentTypesValue.d.ts +21 -0
  58. package/dist/types/ts4.1/text/discoverDashTextTracks.d.ts +2 -2
  59. package/dist/types/ts4.1/track/mse/MseTrack.d.ts +0 -1
  60. package/dist/types/ts4.7/ad/AdControllerImpl.d.ts +18 -2
  61. package/dist/types/ts4.7/drm/DrmControllerImpl.d.ts +6 -0
  62. package/dist/types/ts4.7/playback/createStallDetector.d.ts +43 -0
  63. package/dist/types/ts4.7/playback/createStallMonitor.d.ts +45 -0
  64. package/dist/types/ts4.7/streaming/AudioOptions.d.ts +27 -0
  65. package/dist/types/ts4.7/streaming/LoadMetric.d.ts +15 -1
  66. package/dist/types/ts4.7/streaming/createTimelineContentTypesValue.d.ts +21 -0
  67. package/dist/types/ts4.7/text/discoverDashTextTracks.d.ts +2 -2
  68. package/dist/types/ts4.7/track/mse/MseTrack.d.ts +0 -1
  69. package/dist/unit/types/current/ad/AdControllerImpl.test.js +203 -5
  70. package/dist/unit/types/current/ad/discoverHlsInterstitials.test.js +2 -1
  71. package/dist/unit/types/current/drm/DrmControllerImpl.test.js +30 -1
  72. package/dist/unit/types/current/playback/createStallDetector.test.d.ts +2 -0
  73. package/dist/unit/types/current/playback/createStallDetector.test.d.ts.map +1 -0
  74. package/dist/unit/types/current/playback/createStallDetector.test.js +163 -0
  75. package/dist/unit/types/current/playback/createStallMonitor.test.d.ts +2 -0
  76. package/dist/unit/types/current/playback/createStallMonitor.test.d.ts.map +1 -0
  77. package/dist/unit/types/current/playback/createStallMonitor.test.js +163 -0
  78. package/dist/unit/types/current/streaming/MseTrack.test.js +15 -0
  79. package/dist/unit/types/current/streaming/createTimelineContentTypesValue.test.d.ts +2 -0
  80. package/dist/unit/types/current/streaming/createTimelineContentTypesValue.test.d.ts.map +1 -0
  81. package/dist/unit/types/current/streaming/createTimelineContentTypesValue.test.js +57 -0
  82. package/dist/unit/types/current/text/TextTrack.test.d.ts +2 -0
  83. package/dist/unit/types/current/text/TextTrack.test.d.ts.map +1 -0
  84. package/dist/unit/types/current/text/TextTrack.test.js +12 -0
  85. package/dist/unit/types/current/text/discoverDashTextTracks.test.js +17 -0
  86. package/dist/unit/types/current/text/parseWebVtt.test.js +26 -0
  87. package/dist/unit/types/current/tsconfig.tsbuildinfo +1 -1
  88. package/dist/vinylTestUtil/types/current/tsconfig.tsbuildinfo +1 -1
  89. package/package.json +9 -9
@@ -0,0 +1,45 @@
1
+ import { type Timestamp } from '@amazon/vinyl-util';
2
+ import type { ReadonlyPlaybackController } from './ReadonlyPlaybackController';
3
+ import { StallEndedReason } from './ReadonlyPlaybackController';
4
+ export interface StallMonitorOptions {
5
+ /**
6
+ * Seconds the play head must be frozen (no `timeUpdate`) while playing before a stall is
7
+ * reported. Default: 1
8
+ */
9
+ readonly stallThreshold?: number;
10
+ /**
11
+ * How often (seconds) the play head freeze is checked. Default: 0.25
12
+ */
13
+ readonly pollInterval?: number;
14
+ }
15
+ export interface StallEndedInfo {
16
+ /** Time of the last `timeUpdate` observed before the play head froze. */
17
+ readonly started: Timestamp;
18
+ /** Time the stall ended. */
19
+ readonly ended: Timestamp;
20
+ /** Total seconds the play head was frozen. */
21
+ readonly duration: number;
22
+ /** Why the stall ended. */
23
+ readonly reason: StallEndedReason;
24
+ }
25
+ export interface StallMonitorCallbacks {
26
+ /** Called when a stall is first detected. */
27
+ readonly onStallEntered: () => void;
28
+ /** Called when a detected stall ends. */
29
+ readonly onStallEnded: (info: StallEndedInfo) => void;
30
+ }
31
+ /**
32
+ * Watches a {@link ReadonlyPlaybackController} for stalls while playing: the play head freezing
33
+ * (no `timeUpdate`) for longer than {@link StallMonitorOptions.stallThreshold} seconds.
34
+ *
35
+ * Detection only runs once playback has actually been observed (a `playing` event) and is
36
+ * suspended on `pause`/`seeking`/`emptied` until playback is observed again — so a stall is never
37
+ * reported during initial loading or while awaiting a seek. A detected stall ends when the play
38
+ * head advances again (`playing`) or playback stops for another reason (`pause`/`seeking`/
39
+ * `emptied`); {@link StallEndedInfo.duration} is measured from the last `timeUpdate` before the
40
+ * freeze, so it reflects the full frozen time rather than only the time past the threshold.
41
+ *
42
+ * Returns a function that stops the monitor.
43
+ */
44
+ export declare function createStallMonitor(controller: ReadonlyPlaybackController, callbacks: StallMonitorCallbacks, options?: StallMonitorOptions): () => void;
45
+ //# sourceMappingURL=createStallMonitor.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import type { ObjectSchema } from '@amazon/vinyl-validation';
2
+ import type { Maybe } from '@amazon/vinyl-util';
2
3
  /**
3
4
  * Criteria used to pick which audio rendition to play.
4
5
  */
@@ -33,6 +34,32 @@ export interface AudioOptions {
33
34
  * The audio selection criteria (language / descriptive).
34
35
  */
35
36
  readonly selection?: AudioSelection;
37
+ /**
38
+ * When `true`, disables the audio sampling-rate support filter, so audio
39
+ * renditions whose sampling rate exceeds what the platform reports (via the
40
+ * `AudioContext` output rate) are kept and offered to playback anyway.
41
+ * `false` (the default) applies the filter. Changing this re-filters the
42
+ * timeline immediately.
43
+ */
44
+ readonly disableSampleRateFilter?: boolean;
45
+ /**
46
+ * The maximum audio sampling rate (Hz) the sampling-rate filter treats as
47
+ * supported. When set, it takes precedence over the platform's
48
+ * `AudioContext`-reported output rate. Ignored when
49
+ * {@link disableSampleRateFilter} is `true`. Changing this re-filters the
50
+ * timeline immediately. `null`/`undefined` falls back to the reported rate.
51
+ */
52
+ readonly maxSampleRate?: Maybe<number>;
53
+ /**
54
+ * The minimum audio sampling rate (Hz) the sampling-rate filter treats as
55
+ * supported. When set, renditions below it are filtered out as a soft floor
56
+ * (if every audio rendition is below it, the highest is kept so playback is
57
+ * never stranded). Applied alongside the max cap whenever the filter runs,
58
+ * regardless of how the max was determined; ignored when
59
+ * {@link disableSampleRateFilter} is `true`. Changing this re-filters the
60
+ * timeline immediately.
61
+ */
62
+ readonly minSampleRate?: Maybe<number>;
36
63
  }
37
64
  export declare const defaultAudioOptions: AudioOptions;
38
65
  export declare const audioOptionsValidator: ObjectSchema<AudioOptions>;
@@ -1,14 +1,17 @@
1
1
  import type { Timestamp } from '@amazon/vinyl-util';
2
2
  import type { TrackUri } from '../track/Track';
3
+ import type { ContentType } from './MediaQualityMetadata';
3
4
  /**
4
5
  * The stage of the track-load journey a {@link LoadSpanMeasurement} describes.
5
6
  *
6
7
  * - `manifest`: fetching and parsing the DASH/HLS manifest.
8
+ * - `keySetup`: the EME/CDM setup, from session creation to the CDM's first
9
+ * license message. Precedes and is contiguous with `license`.
7
10
  * - `license`: the DRM license request/response exchange.
8
11
  * - `initSegment`: fetching the initialization segment.
9
12
  * - `firstSegment`: fetching the first media segment.
10
13
  */
11
- export type LoadSpanKind = 'manifest' | 'license' | 'initSegment' | 'firstSegment';
14
+ export type LoadSpanKind = 'manifest' | 'keySetup' | 'license' | 'initSegment' | 'firstSegment';
12
15
  /**
13
16
  * A timed span for one stage of a track load.
14
17
  *
@@ -35,6 +38,17 @@ export interface LoadSpanMeasurement {
35
38
  * buffering DRM info is set).
36
39
  */
37
40
  readonly trackUri?: TrackUri;
41
+ /**
42
+ * The MIME type of the content this span was measured for, when known. Set
43
+ * for DRM `license` spans so a certificate exchange and the per-content-type
44
+ * license exchanges are distinguishable.
45
+ */
46
+ readonly mimeType?: string;
47
+ /**
48
+ * The content type this span was measured for, when known. Set alongside
49
+ * {@link mimeType} for DRM `license` spans.
50
+ */
51
+ readonly contentType?: ContentType;
38
52
  }
39
53
  /**
40
54
  * A {@link LoadSpanMeasurement} attributed to the track it was measured for.
@@ -0,0 +1,21 @@
1
+ import type { ContentTypesValue } from './ContentTypesValue';
2
+ import type { MediaTimeline } from './MediaTimeline';
3
+ import type { ObservableValue } from '@amazon/vinyl-observable';
4
+ export interface TimelineContentTypesValueDeps {
5
+ /**
6
+ * The filtered, playable timeline. Content types are derived from the
7
+ * qualities that survive filtering, so a content type whose renditions are
8
+ * all filtered out (e.g. every audio rendition dropped by the sample-rate
9
+ * filter) is not reported — it never gates readiness and no source buffer
10
+ * is expected for it, so the remaining content types still play.
11
+ */
12
+ readonly mediaTimelineTransformed: ObservableValue<Promise<MediaTimeline>>;
13
+ }
14
+ /**
15
+ * Provides the MSE content types to create source buffers for, derived from the
16
+ * qualities that survive timeline filtering rather than the raw manifest. Only
17
+ * `'audio'`/`'video'` are reported; text is a sidecar pipeline, never an MSE
18
+ * source buffer.
19
+ */
20
+ export declare function createTimelineContentTypesValue(deps: TimelineContentTypesValueDeps): ContentTypesValue;
21
+ //# sourceMappingURL=createTimelineContentTypesValue.d.ts.map
@@ -5,8 +5,8 @@ import type { TextTrackInfo } from './TextTrack';
5
5
  *
6
6
  * Considers an `<AdaptationSet>` to carry text when one of:
7
7
  * - `contentType="text"`
8
- * - `mimeType` starts with `text/` (e.g. `text/vtt`)
9
- * - `mimeType` is `application/mp4` *and* the codec/role indicates text
8
+ * - its `mimeType`, or that of one of its `<Representation>`s, starts with
9
+ * `text/` (e.g. `text/vtt`)
10
10
  *
11
11
  * Within a text adaptation set, each `<Representation>` becomes one
12
12
  * {@link TextTrackInfo}. The `BaseURL` chain (MPD → Period → AdaptationSet →
@@ -26,7 +26,6 @@ export declare class MseTrack extends TrackBase {
26
26
  get [Symbol.toStringTag](): string;
27
27
  protected readonly deps: MseTrackDeps;
28
28
  private readonly streams;
29
- private readonly disposeAbort;
30
29
  private lastPreloadOptions;
31
30
  private activateOptions;
32
31
  private readonly allFetchedRanges;
@@ -6,11 +6,22 @@ import { AdControllerImpl } from '@amazon/vinyl';
6
6
  import { MockPlaybackController, MockTrack } from '@amazon/vinyl/vinylTestUtil';
7
7
  describe('AdControllerImpl', () => {
8
8
  let playbackController;
9
+ let controllers;
9
10
  beforeEach(() => {
10
11
  playbackController = new MockPlaybackController();
12
+ controllers = [];
13
+ });
14
+ // Dispose every controller a spec created. An activated-but-unstarted ad
15
+ // arms a `sleep(adLoadTimeout)` timer.
16
+ afterEach(() => {
17
+ for (const c of controllers)
18
+ if (!c.disposed)
19
+ c.dispose();
11
20
  });
12
21
  function createController() {
13
- return new AdControllerImpl({ playbackController });
22
+ const c = new AdControllerImpl({ playbackController });
23
+ controllers.push(c);
24
+ return c;
14
25
  }
15
26
  function updateTime(time) {
16
27
  playbackController.currentTime = time;
@@ -301,6 +312,21 @@ describe('AdControllerImpl', () => {
301
312
  await flush();
302
313
  expect(c.currentAdBreak).toBeNull();
303
314
  });
315
+ it('ignores a late provider ad-discovery resolution after dispose', async () => {
316
+ const c = createController();
317
+ let resolveAds = () => { };
318
+ const track = new MockTrack();
319
+ track.uri = 't1';
320
+ track.active = true;
321
+ track.getAds = () => new Promise((res) => {
322
+ resolveAds = res;
323
+ });
324
+ c.setAdsProvider(track); // refreshAds() runs; getAds() stays pending
325
+ c.dispose();
326
+ resolveAds(trackAds(makeBreak())); // refreshAds .then: interrupted (disposed)
327
+ await flush();
328
+ expect(c.currentTrackAds).toBeNull();
329
+ });
304
330
  });
305
331
  it('emits currentTrackAdsChange with the TrackAds when a parent track is set', async () => {
306
332
  const c = createController();
@@ -500,6 +526,44 @@ describe('AdControllerImpl', () => {
500
526
  await flush();
501
527
  expect(c.currentAdBreak).toBeNull();
502
528
  });
529
+ it('still enters a pending break when skipped while its ads resolve', async () => {
530
+ const c = createController();
531
+ let resolveAds = () => undefined;
532
+ const entered = [];
533
+ await setContent(c, trackAds(makeBreak({
534
+ id: 'b1',
535
+ startTime: 0,
536
+ duration: 30,
537
+ ads: () => new Promise((resolve) => {
538
+ resolveAds = resolve;
539
+ }),
540
+ }), makeBreak({
541
+ id: 'b2',
542
+ startTime: 1,
543
+ duration: 30,
544
+ ads: [
545
+ {
546
+ id: 'a2',
547
+ startTime: 1,
548
+ duration: 10,
549
+ uri: 'ad2.m3u8',
550
+ },
551
+ ],
552
+ })));
553
+ c.on('adBreakEntered', (e) => entered.push(e.adBreak.id));
554
+ // Both breaks contain the playhead, so both become pending and the
555
+ // first is entered while its ad list is still in flight.
556
+ updateTime(2);
557
+ await flush();
558
+ expect(entered).toEqual(['b1']);
559
+ c.skipAdBreak();
560
+ resolveAds([]);
561
+ await flush();
562
+ // Nothing else drains the pending list, and a non-empty list blocks
563
+ // further midroll ingress, so b2 must be entered here.
564
+ expect(entered).toEqual(['b1', 'b2']);
565
+ expect(c.currentAdBreak?.id).toBe('b2');
566
+ });
503
567
  });
504
568
  describe('a break with no ads', () => {
505
569
  it('completes immediately with a resume position and plays no ad', async () => {
@@ -727,6 +791,34 @@ describe('AdControllerImpl', () => {
727
791
  await flush();
728
792
  expect(thirds).toEqual(['a1']);
729
793
  });
794
+ it('dispatches each quartile at most once per ad', async () => {
795
+ const c = createController();
796
+ const events = [];
797
+ c.on('adFirstQuartile', () => events.push('first'));
798
+ c.on('adMidpoint', () => events.push('mid'));
799
+ c.on('adThirdQuartile', () => events.push('third'));
800
+ await setContent(c, trackAds(makeBreak({ startTime: 0, duration: 10 })));
801
+ updateTime(0);
802
+ await flush();
803
+ playbackController.dispatch('playing', {});
804
+ playbackController.duration = 10;
805
+ playbackController.playbackRate = 1;
806
+ // Every subsequent timeUpdate is still past each threshold, so an
807
+ // unlatched check would report the quartile again.
808
+ for (const [percent, time] of [
809
+ [0.3, 3],
810
+ [0.4, 4],
811
+ [0.6, 6],
812
+ [0.7, 7],
813
+ [0.8, 8],
814
+ [0.9, 9],
815
+ ]) {
816
+ playbackController.currentTimePercent = percent;
817
+ updateTime(time);
818
+ await flush();
819
+ }
820
+ expect(events).toEqual(['first', 'mid', 'third']);
821
+ });
730
822
  it('does not emit quartiles or throw when the ad duration is zero', async () => {
731
823
  // Regression: a zero reported duration must not divide-by-zero into
732
824
  // NaN progress and must not emit bogus quartile events.
@@ -870,7 +962,6 @@ describe('AdControllerImpl', () => {
870
962
  await setContent(c, trackAds(makeBreak({
871
963
  startTime: 10,
872
964
  duration: 20,
873
- // Null per-ad duration so the per-ad cap does not end it.
874
965
  ads: [
875
966
  {
876
967
  id: 'a1',
@@ -964,7 +1055,11 @@ describe('AdControllerImpl', () => {
964
1055
  });
965
1056
  });
966
1057
  describe('ad duration and playout limits', () => {
967
- it('advances to the next ad when one reaches its declared duration', async () => {
1058
+ it('advances to the next ad on the ad media ended, not its declared duration', async () => {
1059
+ // Each ad asset is its own source; a non-final ad advances on its own
1060
+ // media `ended`. The declared per-ad duration must NOT end it early —
1061
+ // a packager may under-declare it (whole-second rounding), which would
1062
+ // otherwise truncate the real asset.
968
1063
  const c = createController();
969
1064
  await setContent(c, trackAds(makeBreak({
970
1065
  startTime: 0,
@@ -973,7 +1068,7 @@ describe('AdControllerImpl', () => {
973
1068
  {
974
1069
  id: 'a1',
975
1070
  startTime: 0,
976
- duration: 5,
1071
+ duration: 5, // declared, server-rounded
977
1072
  uri: 'ad1.m3u8',
978
1073
  },
979
1074
  {
@@ -988,10 +1083,54 @@ describe('AdControllerImpl', () => {
988
1083
  await flush();
989
1084
  expect(c.currentAd?.id).toBe('a1');
990
1085
  playbackController.dispatch('playing', {}); // timeStart = 0
991
- updateTime(6); // elapsed 6 >= a1.duration 5 → advance
1086
+ // Past the declared 5s, still within a1's real content — must NOT advance.
1087
+ updateTime(6);
1088
+ await flush();
1089
+ expect(c.currentAd?.id).toBe('a1');
1090
+ // a1's media ends -> advance to a2.
1091
+ playbackController.dispatch('ended', {
1092
+ previous: false,
1093
+ current: true,
1094
+ });
992
1095
  await flush();
993
1096
  expect(c.currentAd?.id).toBe('a2');
994
1097
  });
1098
+ it('does not cut the final/only ad at its declared duration; it ends on media ended', async () => {
1099
+ // The ad server declares DURATION in whole seconds, but the asset's
1100
+ // real content is longer (6.732s here). The only ad must play to its
1101
+ // media `ended`, not be truncated at the rounded declared duration.
1102
+ const c = createController();
1103
+ const ended = [];
1104
+ c.on('adEnded', (e) => ended.push(e.ad.id));
1105
+ await setContent(c, trackAds(makeBreak({
1106
+ startTime: 0,
1107
+ duration: 30,
1108
+ ads: [
1109
+ {
1110
+ id: 'a1',
1111
+ startTime: 0,
1112
+ duration: 6, // declared, server-rounded
1113
+ uri: 'ad1.m3u8',
1114
+ },
1115
+ ],
1116
+ })));
1117
+ updateTime(0);
1118
+ await flush();
1119
+ expect(c.currentAd?.id).toBe('a1');
1120
+ playbackController.dispatch('playing', {}); // timeStart = 0
1121
+ // Past the declared 6s, into the real content — must NOT end yet.
1122
+ updateTime(6.5);
1123
+ await flush();
1124
+ expect(c.currentAd?.id).toBe('a1');
1125
+ expect(ended).toEqual([]);
1126
+ // Real media end at 6.732 ends the ad.
1127
+ playbackController.dispatch('ended', {
1128
+ previous: false,
1129
+ current: true,
1130
+ });
1131
+ expect(ended).toEqual(['a1']);
1132
+ expect(c.currentAdBreak).toBeNull();
1133
+ });
995
1134
  it('ends the whole break when the playout limit is reached, dropping remaining ads', async () => {
996
1135
  const c = createController();
997
1136
  const resumes = [];
@@ -1027,6 +1166,65 @@ describe('AdControllerImpl', () => {
1027
1166
  // content still resumes at the cue point (0), not startTime + playout.
1028
1167
  expect(resumes.at(-1)).toBe(0);
1029
1168
  });
1169
+ it('does not cut at the playout limit when the ad is within tolerance of its media end', async () => {
1170
+ // The budget is reached, but the ad's real media end is <1s away, so
1171
+ // it should finish on its own `ended` rather than be clipped. The
1172
+ // tolerance keys off the playback-reported duration, not ad.duration.
1173
+ const c = createController();
1174
+ await setContent(c, trackAds(makeBreak({
1175
+ startTime: 0,
1176
+ duration: null,
1177
+ playoutLimit: 8,
1178
+ ads: [
1179
+ {
1180
+ id: 'a1',
1181
+ startTime: 0,
1182
+ duration: 20,
1183
+ uri: 'ad1.m3u8',
1184
+ },
1185
+ ],
1186
+ })));
1187
+ updateTime(0);
1188
+ await flush();
1189
+ playbackController.dispatch('playing', {}); // timeStart = 0
1190
+ // Media is 8.5s long; at the 8s budget only 0.5s of media remains.
1191
+ playbackController.duration = 8.5;
1192
+ updateTime(8); // cumulative 8 >= limit 8, but within 1s of media end
1193
+ await flush();
1194
+ expect(c.currentAdBreak).not.toBeNull();
1195
+ expect(c.currentAd?.id).toBe('a1');
1196
+ // It finishes on its own media `ended`.
1197
+ playbackController.dispatch('ended', {
1198
+ previous: false,
1199
+ current: true,
1200
+ });
1201
+ await flush();
1202
+ expect(c.currentAdBreak).toBeNull();
1203
+ });
1204
+ it('cuts at the playout limit when the ad has more than the tolerance of media left', async () => {
1205
+ const c = createController();
1206
+ await setContent(c, trackAds(makeBreak({
1207
+ startTime: 0,
1208
+ duration: null,
1209
+ playoutLimit: 8,
1210
+ ads: [
1211
+ {
1212
+ id: 'a1',
1213
+ startTime: 0,
1214
+ duration: 20,
1215
+ uri: 'ad1.m3u8',
1216
+ },
1217
+ ],
1218
+ })));
1219
+ updateTime(0);
1220
+ await flush();
1221
+ playbackController.dispatch('playing', {}); // timeStart = 0
1222
+ // Media is 20s long; at the 8s budget 12s of media remains → cut.
1223
+ playbackController.duration = 20;
1224
+ updateTime(8);
1225
+ await flush();
1226
+ expect(c.currentAdBreak).toBeNull();
1227
+ });
1030
1228
  });
1031
1229
  describe('CUE=ONCE and replay', () => {
1032
1230
  it('replays a non-ONCE break after the playhead seeks back before it', async () => {
@@ -199,7 +199,8 @@ describe('discoverHlsInterstitials', () => {
199
199
  const ads = await resolveValueProvider(breaks[0].ads);
200
200
  expect(fetchSpy.calls.argsFor(0)[0]).toBe('https://example.com/ads.json');
201
201
  expect(ads.length).toBe(2);
202
- expect(ads[0].uri).toBe('https://cdn.example.com/media/mid1.m3u8');
202
+ // Relative to the asset list, not to the media playlist.
203
+ expect(ads[0].uri).toBe('https://example.com/mid1.m3u8');
203
204
  expect(ads[0].duration).toBe(10);
204
205
  expect(ads[1].uri).toBe('https://cdn.example.com/mid2.m3u8');
205
206
  expect(ads[1].duration).toBeNull();
@@ -2,7 +2,7 @@
2
2
  * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
- import { createEmptyMediaQualityMetadata, DrmControllerImpl, DrmError, DrmKeySystem, DrmRobustness, LICENSE_TIMEOUT, ReportableMediaError, } from '@amazon/vinyl';
5
+ import { createEmptyMediaQualityMetadata, DrmControllerImpl, DrmError, DrmKeySystem, DrmRobustness, EXCESSIVE_SESSION_COUNT, LICENSE_TIMEOUT, ReportableMediaError, } from '@amazon/vinyl';
6
6
  import { createEventSpy, useMockLogger } from '@amazon/vinyl-util/testUtil';
7
7
  import { Abort, base64ToByteArray, bufferToBase64, Deferred, ErrorLevel, ErrorOrigin, never, nextEventAsPromise, ReportableError, setUserAgent, utf16ToUint16Array, } from '@amazon/vinyl-util';
8
8
  import { flushPromises, implementEventFakes, MockHTMLAudioElement, MockMediaError, useMockTime, } from '@amazon/vinyl-util/browserTestUtil';
@@ -331,6 +331,17 @@ describe('DrmControllerImpl', () => {
331
331
  expect(sessions.map((session) => session.dispose.calls.count())).toEqual([1, 1, 1, 1, 1, 1]); // Expect all sessions to have been disposed
332
332
  expect(drmController.activeSessions).toEqual(0);
333
333
  });
334
+ it('does not close sessions again after dispose', async () => {
335
+ const abort = new Abort();
336
+ drmController.setBufferingDrmInfo(drmInfo, abort);
337
+ await emitEncrypted();
338
+ const session = mediaKeys.createSession.calls.mostRecent()
339
+ .returnValue;
340
+ drmController.dispose();
341
+ const disposeCount = session.dispose.calls.count();
342
+ abort.abort();
343
+ expect(session.dispose.calls.count()).toBe(disposeCount);
344
+ });
334
345
  });
335
346
  });
336
347
  describe('when an encrypted event is observed', () => {
@@ -1048,6 +1059,24 @@ describe('DrmControllerImpl', () => {
1048
1059
  expect(drmController.isEmeSupported()).toBeFalse();
1049
1060
  });
1050
1061
  });
1062
+ describe('excessive sessions', () => {
1063
+ it('warns once when the open session count exceeds the threshold', async () => {
1064
+ drmController.setBufferingDrmInfo(drmInfo);
1065
+ // Distinct init data per event so each opens its own session.
1066
+ for (let i = 0; i < EXCESSIVE_SESSION_COUNT; i++) {
1067
+ await emitEncrypted(new Uint8Array([i]));
1068
+ }
1069
+ expect(drmController.activeSessions).toBe(EXCESSIVE_SESSION_COUNT);
1070
+ expect(logger.value.warn).not.toHaveBeenCalled();
1071
+ // Crossing the threshold warns.
1072
+ await emitEncrypted(new Uint8Array([EXCESSIVE_SESSION_COUNT]));
1073
+ expect(drmController.activeSessions).toBe(EXCESSIVE_SESSION_COUNT + 1);
1074
+ expect(logger.value.warn).toHaveBeenCalledTimes(1);
1075
+ // Further sessions past the threshold don't re-warn.
1076
+ await emitEncrypted(new Uint8Array([EXCESSIVE_SESSION_COUNT + 1]));
1077
+ expect(logger.value.warn).toHaveBeenCalledTimes(1);
1078
+ });
1079
+ });
1051
1080
  describe('closeSessions', () => {
1052
1081
  it('clears active sessions', async () => {
1053
1082
  const sessionCloseSpy = createEventSpy(drmController, 'sessionClose');
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=createStallDetector.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createStallDetector.test.d.ts","sourceRoot":"","sources":["../../../../../test/unit/playback/createStallDetector.test.ts"],"names":[],"mappings":""}