@amazon/vinyl 3.1.0 → 3.2.1
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/index.cjs +236 -187
- package/dist/index.cjs.map +4 -4
- package/dist/index.js +224 -174
- package/dist/index.js.map +4 -4
- package/dist/test/integ.es5.js +245 -231
- package/dist/test/integ.js +676 -271
- package/dist/test/integ.js.map +4 -4
- package/dist/types/current/ad/AdControllerImpl.d.ts +18 -2
- package/dist/types/current/ad/AdControllerImpl.d.ts.map +1 -1
- package/dist/types/current/ad/AdControllerImpl.js +42 -20
- package/dist/types/current/drm/DrmControllerImpl.d.ts +17 -1
- package/dist/types/current/drm/DrmControllerImpl.d.ts.map +1 -1
- package/dist/types/current/drm/DrmControllerImpl.js +45 -10
- package/dist/types/current/playback/createStallMonitor.d.ts.map +1 -1
- package/dist/types/current/playback/createStallMonitor.js +19 -14
- package/dist/types/current/streaming/LoadMetric.d.ts +15 -1
- package/dist/types/current/streaming/LoadMetric.d.ts.map +1 -1
- package/dist/types/current/streaming/mediaTimelineFilter.d.ts +4 -2
- package/dist/types/current/streaming/mediaTimelineFilter.d.ts.map +1 -1
- package/dist/types/current/streaming/mediaTimelineFilter.js +23 -8
- package/dist/types/current/track/filters/sampleRateFilter.d.ts +8 -4
- package/dist/types/current/track/filters/sampleRateFilter.d.ts.map +1 -1
- package/dist/types/current/track/filters/sampleRateFilter.js +31 -21
- package/dist/types/current/track/mse/MseTrack.d.ts +0 -9
- package/dist/types/current/track/mse/MseTrack.d.ts.map +1 -1
- package/dist/types/current/track/mse/MseTrack.js +2 -29
- package/dist/types/current/tsconfig.tsbuildinfo +1 -1
- package/dist/types/current/vinyl/VinylPlayer.d.ts +5 -0
- package/dist/types/current/vinyl/VinylPlayer.d.ts.map +1 -1
- package/dist/types/current/vinyl/VinylPlayer.js +23 -1
- package/dist/types/ts3.7/ad/AdControllerImpl.d.ts +18 -2
- package/dist/types/ts3.7/drm/DrmControllerImpl.d.ts +17 -1
- package/dist/types/ts3.7/streaming/LoadMetric.d.ts +15 -1
- package/dist/types/ts3.7/streaming/mediaTimelineFilter.d.ts +4 -2
- package/dist/types/ts3.7/track/filters/sampleRateFilter.d.ts +8 -4
- package/dist/types/ts3.7/track/mse/MseTrack.d.ts +0 -9
- package/dist/types/ts3.7/vinyl/VinylPlayer.d.ts +5 -0
- package/dist/types/ts4.1/ad/AdControllerImpl.d.ts +18 -2
- package/dist/types/ts4.1/drm/DrmControllerImpl.d.ts +17 -1
- package/dist/types/ts4.1/streaming/LoadMetric.d.ts +15 -1
- package/dist/types/ts4.1/streaming/mediaTimelineFilter.d.ts +4 -2
- package/dist/types/ts4.1/track/filters/sampleRateFilter.d.ts +8 -4
- package/dist/types/ts4.1/track/mse/MseTrack.d.ts +0 -9
- package/dist/types/ts4.1/vinyl/VinylPlayer.d.ts +5 -0
- package/dist/types/ts4.7/ad/AdControllerImpl.d.ts +18 -2
- package/dist/types/ts4.7/drm/DrmControllerImpl.d.ts +17 -1
- package/dist/types/ts4.7/streaming/LoadMetric.d.ts +15 -1
- package/dist/types/ts4.7/streaming/mediaTimelineFilter.d.ts +4 -2
- package/dist/types/ts4.7/track/filters/sampleRateFilter.d.ts +8 -4
- package/dist/types/ts4.7/track/mse/MseTrack.d.ts +0 -9
- package/dist/types/ts4.7/vinyl/VinylPlayer.d.ts +5 -0
- package/dist/unit/types/current/ad/AdControllerImpl.test.js +110 -4
- package/dist/unit/types/current/drm/DrmControllerImpl.test.js +53 -9
- package/dist/unit/types/current/playback/createStallMonitor.test.js +38 -0
- package/dist/unit/types/current/streaming/MseTrack.test.js +11 -8
- package/dist/unit/types/current/streaming/mediaTimelineFilter.test.js +60 -54
- package/dist/unit/types/current/track/filters/sampleRateFilter.test.js +75 -0
- package/dist/unit/types/current/tsconfig.tsbuildinfo +1 -1
- package/dist/unit/types/current/vinyl/VinylPlayer.test.js +33 -2
- package/dist/vinylTestUtil/index.js +10 -1
- package/dist/vinylTestUtil/index.js.map +2 -2
- package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.d.ts +5 -0
- package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.d.ts.map +1 -1
- package/dist/vinylTestUtil/types/current/assets/vinylTestAssets.js +9 -0
- package/dist/vinylTestUtil/types/current/tsconfig.tsbuildinfo +1 -1
- package/dist/vinylTestUtil/types/ts3.7/assets/vinylTestAssets.d.ts +5 -0
- package/dist/vinylTestUtil/types/ts4.1/assets/vinylTestAssets.d.ts +5 -0
- package/dist/vinylTestUtil/types/ts4.7/assets/vinylTestAssets.d.ts +5 -0
- package/package.json +10 -10
|
@@ -66,6 +66,11 @@ export declare class VinylPlayer<TrackLoadOptionsType extends TrackLoadOptions =
|
|
|
66
66
|
* @see createVinylPlayer
|
|
67
67
|
*/
|
|
68
68
|
protected constructor(factories: Factories<VinylDeps<TrackLoadOptionsType, OptionsType>>);
|
|
69
|
+
/**
|
|
70
|
+
* Logs each republished load span once at the verbose level, formatting its
|
|
71
|
+
* bounds as ISO date strings and its duration in milliseconds.
|
|
72
|
+
*/
|
|
73
|
+
protected initializeLoadSpanLogging(): void;
|
|
69
74
|
protected redispatchSubControllerEvents(): void;
|
|
70
75
|
/**
|
|
71
76
|
* Certain track events are redispatched on the player for the current track.
|
|
@@ -896,7 +896,6 @@ describe('AdControllerImpl', () => {
|
|
|
896
896
|
await setContent(c, trackAds(makeBreak({
|
|
897
897
|
startTime: 10,
|
|
898
898
|
duration: 20,
|
|
899
|
-
// Null per-ad duration so the per-ad cap does not end it.
|
|
900
899
|
ads: [
|
|
901
900
|
{
|
|
902
901
|
id: 'a1',
|
|
@@ -990,7 +989,11 @@ describe('AdControllerImpl', () => {
|
|
|
990
989
|
});
|
|
991
990
|
});
|
|
992
991
|
describe('ad duration and playout limits', () => {
|
|
993
|
-
it('advances to the next ad
|
|
992
|
+
it('advances to the next ad on the ad media ended, not its declared duration', async () => {
|
|
993
|
+
// Each ad asset is its own source; a non-final ad advances on its own
|
|
994
|
+
// media `ended`. The declared per-ad duration must NOT end it early —
|
|
995
|
+
// a packager may under-declare it (whole-second rounding), which would
|
|
996
|
+
// otherwise truncate the real asset.
|
|
994
997
|
const c = createController();
|
|
995
998
|
await setContent(c, trackAds(makeBreak({
|
|
996
999
|
startTime: 0,
|
|
@@ -999,7 +1002,7 @@ describe('AdControllerImpl', () => {
|
|
|
999
1002
|
{
|
|
1000
1003
|
id: 'a1',
|
|
1001
1004
|
startTime: 0,
|
|
1002
|
-
duration: 5,
|
|
1005
|
+
duration: 5, // declared, server-rounded
|
|
1003
1006
|
uri: 'ad1.m3u8',
|
|
1004
1007
|
},
|
|
1005
1008
|
{
|
|
@@ -1014,10 +1017,54 @@ describe('AdControllerImpl', () => {
|
|
|
1014
1017
|
await flush();
|
|
1015
1018
|
expect(c.currentAd?.id).toBe('a1');
|
|
1016
1019
|
playbackController.dispatch('playing', {}); // timeStart = 0
|
|
1017
|
-
|
|
1020
|
+
// Past the declared 5s, still within a1's real content — must NOT advance.
|
|
1021
|
+
updateTime(6);
|
|
1022
|
+
await flush();
|
|
1023
|
+
expect(c.currentAd?.id).toBe('a1');
|
|
1024
|
+
// a1's media ends -> advance to a2.
|
|
1025
|
+
playbackController.dispatch('ended', {
|
|
1026
|
+
previous: false,
|
|
1027
|
+
current: true,
|
|
1028
|
+
});
|
|
1018
1029
|
await flush();
|
|
1019
1030
|
expect(c.currentAd?.id).toBe('a2');
|
|
1020
1031
|
});
|
|
1032
|
+
it('does not cut the final/only ad at its declared duration; it ends on media ended', async () => {
|
|
1033
|
+
// The ad server declares DURATION in whole seconds, but the asset's
|
|
1034
|
+
// real content is longer (6.732s here). The only ad must play to its
|
|
1035
|
+
// media `ended`, not be truncated at the rounded declared duration.
|
|
1036
|
+
const c = createController();
|
|
1037
|
+
const ended = [];
|
|
1038
|
+
c.on('adEnded', (e) => ended.push(e.ad.id));
|
|
1039
|
+
await setContent(c, trackAds(makeBreak({
|
|
1040
|
+
startTime: 0,
|
|
1041
|
+
duration: 30,
|
|
1042
|
+
ads: [
|
|
1043
|
+
{
|
|
1044
|
+
id: 'a1',
|
|
1045
|
+
startTime: 0,
|
|
1046
|
+
duration: 6, // declared, server-rounded
|
|
1047
|
+
uri: 'ad1.m3u8',
|
|
1048
|
+
},
|
|
1049
|
+
],
|
|
1050
|
+
})));
|
|
1051
|
+
updateTime(0);
|
|
1052
|
+
await flush();
|
|
1053
|
+
expect(c.currentAd?.id).toBe('a1');
|
|
1054
|
+
playbackController.dispatch('playing', {}); // timeStart = 0
|
|
1055
|
+
// Past the declared 6s, into the real content — must NOT end yet.
|
|
1056
|
+
updateTime(6.5);
|
|
1057
|
+
await flush();
|
|
1058
|
+
expect(c.currentAd?.id).toBe('a1');
|
|
1059
|
+
expect(ended).toEqual([]);
|
|
1060
|
+
// Real media end at 6.732 ends the ad.
|
|
1061
|
+
playbackController.dispatch('ended', {
|
|
1062
|
+
previous: false,
|
|
1063
|
+
current: true,
|
|
1064
|
+
});
|
|
1065
|
+
expect(ended).toEqual(['a1']);
|
|
1066
|
+
expect(c.currentAdBreak).toBeNull();
|
|
1067
|
+
});
|
|
1021
1068
|
it('ends the whole break when the playout limit is reached, dropping remaining ads', async () => {
|
|
1022
1069
|
const c = createController();
|
|
1023
1070
|
const resumes = [];
|
|
@@ -1053,6 +1100,65 @@ describe('AdControllerImpl', () => {
|
|
|
1053
1100
|
// content still resumes at the cue point (0), not startTime + playout.
|
|
1054
1101
|
expect(resumes.at(-1)).toBe(0);
|
|
1055
1102
|
});
|
|
1103
|
+
it('does not cut at the playout limit when the ad is within tolerance of its media end', async () => {
|
|
1104
|
+
// The budget is reached, but the ad's real media end is <1s away, so
|
|
1105
|
+
// it should finish on its own `ended` rather than be clipped. The
|
|
1106
|
+
// tolerance keys off the playback-reported duration, not ad.duration.
|
|
1107
|
+
const c = createController();
|
|
1108
|
+
await setContent(c, trackAds(makeBreak({
|
|
1109
|
+
startTime: 0,
|
|
1110
|
+
duration: null,
|
|
1111
|
+
playoutLimit: 8,
|
|
1112
|
+
ads: [
|
|
1113
|
+
{
|
|
1114
|
+
id: 'a1',
|
|
1115
|
+
startTime: 0,
|
|
1116
|
+
duration: 20,
|
|
1117
|
+
uri: 'ad1.m3u8',
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
})));
|
|
1121
|
+
updateTime(0);
|
|
1122
|
+
await flush();
|
|
1123
|
+
playbackController.dispatch('playing', {}); // timeStart = 0
|
|
1124
|
+
// Media is 8.5s long; at the 8s budget only 0.5s of media remains.
|
|
1125
|
+
playbackController.duration = 8.5;
|
|
1126
|
+
updateTime(8); // cumulative 8 >= limit 8, but within 1s of media end
|
|
1127
|
+
await flush();
|
|
1128
|
+
expect(c.currentAdBreak).not.toBeNull();
|
|
1129
|
+
expect(c.currentAd?.id).toBe('a1');
|
|
1130
|
+
// It finishes on its own media `ended`.
|
|
1131
|
+
playbackController.dispatch('ended', {
|
|
1132
|
+
previous: false,
|
|
1133
|
+
current: true,
|
|
1134
|
+
});
|
|
1135
|
+
await flush();
|
|
1136
|
+
expect(c.currentAdBreak).toBeNull();
|
|
1137
|
+
});
|
|
1138
|
+
it('cuts at the playout limit when the ad has more than the tolerance of media left', async () => {
|
|
1139
|
+
const c = createController();
|
|
1140
|
+
await setContent(c, trackAds(makeBreak({
|
|
1141
|
+
startTime: 0,
|
|
1142
|
+
duration: null,
|
|
1143
|
+
playoutLimit: 8,
|
|
1144
|
+
ads: [
|
|
1145
|
+
{
|
|
1146
|
+
id: 'a1',
|
|
1147
|
+
startTime: 0,
|
|
1148
|
+
duration: 20,
|
|
1149
|
+
uri: 'ad1.m3u8',
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
})));
|
|
1153
|
+
updateTime(0);
|
|
1154
|
+
await flush();
|
|
1155
|
+
playbackController.dispatch('playing', {}); // timeStart = 0
|
|
1156
|
+
// Media is 20s long; at the 8s budget 12s of media remains → cut.
|
|
1157
|
+
playbackController.duration = 20;
|
|
1158
|
+
updateTime(8);
|
|
1159
|
+
await flush();
|
|
1160
|
+
expect(c.currentAdBreak).toBeNull();
|
|
1161
|
+
});
|
|
1056
1162
|
});
|
|
1057
1163
|
describe('CUE=ONCE and replay', () => {
|
|
1058
1164
|
it('replays a non-ONCE break after the playhead seeks back before it', async () => {
|
|
@@ -950,28 +950,70 @@ describe('DrmControllerImpl', () => {
|
|
|
950
950
|
drmController.setBufferingDrmInfo(drmInfo);
|
|
951
951
|
await emitEncrypted(new Uint8Array([1, 2, 3]), 'cenc');
|
|
952
952
|
await emitMessage(0, new ArrayBuffer(1));
|
|
953
|
-
expect(spy).
|
|
953
|
+
expect(spy).toHaveBeenCalledWith(objectContaining({
|
|
954
954
|
kind: 'license',
|
|
955
955
|
startTime: any(Number),
|
|
956
956
|
endTime: any(Number),
|
|
957
957
|
}));
|
|
958
958
|
});
|
|
959
|
+
it('measures the key setup, contiguous with and before the license', async () => {
|
|
960
|
+
licenseProvider.and.resolveTo(new Uint8Array([1]).buffer);
|
|
961
|
+
const spy = createEventSpy(drmController, 'loadSpanMeasured');
|
|
962
|
+
drmController.setBufferingDrmInfo(drmInfo, { trackUri: 'track-a' });
|
|
963
|
+
await emitEncrypted(new Uint8Array([1, 2, 3]), 'cenc');
|
|
964
|
+
await emitMessage(0, new ArrayBuffer(1));
|
|
965
|
+
const spans = spy.calls.all().map((call) => call.args[0]);
|
|
966
|
+
expect(spans.map((span) => span.kind)).toEqual([
|
|
967
|
+
'keySetup',
|
|
968
|
+
'license',
|
|
969
|
+
]);
|
|
970
|
+
const [keySetup, license] = spans;
|
|
971
|
+
expect(keySetup.trackUri).toEqual('track-a');
|
|
972
|
+
expect(keySetup.endTime).toEqual(license.startTime);
|
|
973
|
+
});
|
|
974
|
+
it('carries the content mime and content type on the license span', async () => {
|
|
975
|
+
licenseProvider.and.resolveTo(new Uint8Array([1]).buffer);
|
|
976
|
+
const spy = createEventSpy(drmController, 'loadSpanMeasured');
|
|
977
|
+
drmController.setBufferingDrmInfo(drmInfo);
|
|
978
|
+
await emitEncrypted(new Uint8Array([1, 2, 3]), 'cenc');
|
|
979
|
+
await emitMessage(0, new ArrayBuffer(1));
|
|
980
|
+
expect(spy).toHaveBeenCalledWith(objectContaining({
|
|
981
|
+
kind: 'license',
|
|
982
|
+
mimeType: 'audio/mp4',
|
|
983
|
+
contentType: 'audio',
|
|
984
|
+
}));
|
|
985
|
+
});
|
|
959
986
|
it('does not measure a failed license exchange', async () => {
|
|
960
987
|
licenseProvider.and.rejectWith(new Error('license failed'));
|
|
961
988
|
const spy = createEventSpy(drmController, 'loadSpanMeasured');
|
|
962
989
|
drmController.setBufferingDrmInfo(drmInfo);
|
|
963
990
|
await emitEncrypted(new Uint8Array([1, 2, 3]), 'cenc');
|
|
964
991
|
await emitMessage(0, new ArrayBuffer(1));
|
|
965
|
-
|
|
992
|
+
// Setup completed at the message, so keySetup is still measured.
|
|
993
|
+
expect(spy).not.toHaveBeenCalledWith(objectContaining({ kind: 'license' }));
|
|
966
994
|
errorSpy.calls.reset();
|
|
967
995
|
});
|
|
996
|
+
it('measures key setup only for the first session of a track load', async () => {
|
|
997
|
+
licenseProvider.and.resolveTo(new Uint8Array([1]).buffer);
|
|
998
|
+
const spy = createEventSpy(drmController, 'loadSpanMeasured');
|
|
999
|
+
drmController.setBufferingDrmInfo(drmInfo, { trackUri: 'track-a' });
|
|
1000
|
+
await emitEncrypted(new Uint8Array([1]), 'cenc');
|
|
1001
|
+
await emitEncrypted(new Uint8Array([2]), 'cenc');
|
|
1002
|
+
expect(mediaKeys.createSession).toHaveBeenCalledTimes(2);
|
|
1003
|
+
await emitMessage(0, new ArrayBuffer(1));
|
|
1004
|
+
await emitMessage(1, new ArrayBuffer(1));
|
|
1005
|
+
const keySetupSpans = spy.calls
|
|
1006
|
+
.all()
|
|
1007
|
+
.filter((call) => call.args[0].kind === 'keySetup');
|
|
1008
|
+
expect(keySetupSpans.length).toEqual(1);
|
|
1009
|
+
});
|
|
968
1010
|
it('attributes the license span to the buffering track uri', async () => {
|
|
969
1011
|
licenseProvider.and.resolveTo(new Uint8Array([1]).buffer);
|
|
970
1012
|
const spy = createEventSpy(drmController, 'loadSpanMeasured');
|
|
971
1013
|
drmController.setBufferingDrmInfo(drmInfo, { trackUri: 'track-a' });
|
|
972
1014
|
await emitEncrypted(new Uint8Array([1, 2, 3]), 'cenc');
|
|
973
1015
|
await emitMessage(0, new ArrayBuffer(1));
|
|
974
|
-
expect(spy).
|
|
1016
|
+
expect(spy).toHaveBeenCalledWith(objectContaining({
|
|
975
1017
|
kind: 'license',
|
|
976
1018
|
trackUri: 'track-a',
|
|
977
1019
|
}));
|
|
@@ -1005,12 +1047,14 @@ describe('DrmControllerImpl', () => {
|
|
|
1005
1047
|
// on the controller's current buffering track.
|
|
1006
1048
|
await emitMessage(1, new ArrayBuffer(1));
|
|
1007
1049
|
await emitMessage(0, new ArrayBuffer(1));
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
expect(
|
|
1050
|
+
const licenseTrackUris = spy.calls
|
|
1051
|
+
.all()
|
|
1052
|
+
.map((call) => call.args[0])
|
|
1053
|
+
.filter((span) => span.kind === 'license')
|
|
1054
|
+
.map((span) => span.trackUri);
|
|
1055
|
+
expect(licenseTrackUris.length).toEqual(2);
|
|
1056
|
+
expect(licenseTrackUris).toContain('track-preloaded');
|
|
1057
|
+
expect(licenseTrackUris).toContain('track-active');
|
|
1014
1058
|
});
|
|
1015
1059
|
});
|
|
1016
1060
|
describe('serverCertificate', () => {
|
|
@@ -115,10 +115,48 @@ describe('createStallMonitor', () => {
|
|
|
115
115
|
expect(onStallEntered).toHaveBeenCalledTimes(2);
|
|
116
116
|
expect(onStallEnded).toHaveBeenCalledTimes(2);
|
|
117
117
|
});
|
|
118
|
+
it('does not end and re-detect a stall on a non-advancing timeUpdate', async () => {
|
|
119
|
+
// Browsers emit `timeUpdate` even while the play head is frozen, with an unchanged (or
|
|
120
|
+
// briefly backward) currentTime. These must not be treated as progress, or the stall is
|
|
121
|
+
// ended and immediately re-detected, doubling the reported events.
|
|
122
|
+
monitor();
|
|
123
|
+
host.dispatch('playing', {});
|
|
124
|
+
host.dispatch('timeUpdate', { previous: 4, current: 5 });
|
|
125
|
+
await poll(4); // freeze 1s -> stallEntered
|
|
126
|
+
expect(onStallEntered).toHaveBeenCalledTimes(1);
|
|
127
|
+
// Still frozen: currentTime hasn't advanced (unchanged, backward, or sub-epsilon jitter).
|
|
128
|
+
host.dispatch('timeUpdate', { previous: 5, current: 5 });
|
|
129
|
+
host.dispatch('timeUpdate', { previous: 5, current: 4.99 });
|
|
130
|
+
host.dispatch('timeUpdate', { previous: 5, current: 5 + 1e-9 });
|
|
131
|
+
await poll(4);
|
|
132
|
+
expect(onStallEnded).not.toHaveBeenCalled();
|
|
133
|
+
expect(onStallEntered).toHaveBeenCalledTimes(1);
|
|
134
|
+
// A real forward advance ends the stall exactly once.
|
|
135
|
+
host.dispatch('timeUpdate', { previous: 5, current: 6 });
|
|
136
|
+
expect(onStallEnded).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(onStallEntered).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
it('does not re-detect a stall immediately after resuming via a playing event', async () => {
|
|
140
|
+
// Resuming via `playing` must restart the freeze clock. Otherwise the poll, still seeing
|
|
141
|
+
// the stale pre-freeze progress time, immediately re-enters a stall before the first
|
|
142
|
+
// post-resume timeUpdate arrives — an extra, spurious stall.
|
|
143
|
+
monitor();
|
|
144
|
+
host.dispatch('playing', {});
|
|
145
|
+
host.dispatch('timeUpdate', { previous: 0, current: 1 });
|
|
146
|
+
await poll(4); // freeze 1s -> stallEntered
|
|
147
|
+
expect(onStallEntered).toHaveBeenCalledTimes(1);
|
|
148
|
+
// Resume with a 'playing' event and no timeUpdate yet.
|
|
149
|
+
host.dispatch('playing', {});
|
|
150
|
+
expect(onStallEnded).toHaveBeenCalledTimes(1);
|
|
151
|
+
// The freeze clock reset on resume, so a sub-threshold gap is not a new stall.
|
|
152
|
+
await poll(3); // 0.75s < 1s threshold
|
|
153
|
+
expect(onStallEntered).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
118
155
|
it('stops reporting after disposal', async () => {
|
|
119
156
|
monitor();
|
|
120
157
|
host.dispatch('playing', {});
|
|
121
158
|
stop();
|
|
159
|
+
stop = undefined; // disposal is single-shot; don't dispose again in afterEach
|
|
122
160
|
await poll(8);
|
|
123
161
|
expect(onStallEntered).not.toHaveBeenCalled();
|
|
124
162
|
});
|
|
@@ -7,7 +7,7 @@ import { createMockDashDependencies, } from '@amazon/vinyl/vinylTestUtil';
|
|
|
7
7
|
import { createEventSpy, useMockLogger } from '@amazon/vinyl-util/testUtil';
|
|
8
8
|
import { flushPromises } from '@amazon/vinyl-util/browserTestUtil';
|
|
9
9
|
import { externalDependencies } from '@amazon/vinyl-di';
|
|
10
|
-
import { Abort
|
|
10
|
+
import { Abort } from '@amazon/vinyl-util';
|
|
11
11
|
var any = jasmine.any;
|
|
12
12
|
describe('MseTrack', () => {
|
|
13
13
|
useMockLogger();
|
|
@@ -585,11 +585,12 @@ describe('MseTrack', () => {
|
|
|
585
585
|
expect(track.qualities).toEqual([qualityMetadata]);
|
|
586
586
|
expect(track.qualitiesUnfiltered).toEqual([qualityMetadata]);
|
|
587
587
|
});
|
|
588
|
-
it('
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
588
|
+
it('plays the surviving content type when another is entirely filtered out', async () => {
|
|
589
|
+
// The content types now come from the FILTERED timeline: with every
|
|
590
|
+
// audio rendition filtered, only 'video' is required. No audio stream
|
|
591
|
+
// is created, so the load is never stranded waiting for an audio
|
|
592
|
+
// source buffer that will never arrive, and the track does not error.
|
|
593
|
+
deps.contentTypesValue.value = Promise.resolve(new Set(['video']));
|
|
593
594
|
const videoQuality = {
|
|
594
595
|
...createEmptyMediaQualityMetadata(),
|
|
595
596
|
contentType: 'video',
|
|
@@ -614,8 +615,10 @@ describe('MseTrack', () => {
|
|
|
614
615
|
});
|
|
615
616
|
track = createTrack();
|
|
616
617
|
await flushPromises();
|
|
617
|
-
expect(track.error).
|
|
618
|
-
expect(track.
|
|
618
|
+
expect(track.error).toBeNull();
|
|
619
|
+
expect(track.contentTypes).toEqual(new Set(['video']));
|
|
620
|
+
expect(getStream('video')).not.toBeNull();
|
|
621
|
+
expect(getStream('audio')).toBeNull();
|
|
619
622
|
});
|
|
620
623
|
it('reads qualities from the filtered timeline but qualitiesUnfiltered from the raw one', async () => {
|
|
621
624
|
// The transformed timeline is filtered (e.g. by preferred audio
|
|
@@ -13,81 +13,87 @@ function createQuality(contentType, bandwidth) {
|
|
|
13
13
|
getSegment: () => Promise.resolve(null),
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
periods: [
|
|
19
|
-
{
|
|
20
|
-
startTime: 0,
|
|
21
|
-
endTime: 10,
|
|
22
|
-
qualities: [
|
|
23
|
-
createQuality('audio', 128000),
|
|
24
|
-
createQuality('audio', 256000),
|
|
25
|
-
createQuality('video', 500000),
|
|
26
|
-
],
|
|
27
|
-
},
|
|
28
|
-
],
|
|
16
|
+
function timelineOf(qualities) {
|
|
17
|
+
return {
|
|
18
|
+
periods: [{ startTime: 0, endTime: 10, qualities: [...qualities] }],
|
|
29
19
|
minBufferTime: 2,
|
|
30
20
|
getAdBreaks: () => Promise.resolve([]),
|
|
31
21
|
getDuration: () => Promise.resolve(Infinity),
|
|
32
22
|
};
|
|
23
|
+
}
|
|
24
|
+
function contentTypesOf(timeline) {
|
|
25
|
+
return new Set(timeline.periods[0].qualities.map((q) => q.metadata.contentType));
|
|
26
|
+
}
|
|
27
|
+
function throwEmpty() {
|
|
28
|
+
throw new Error('no qualities');
|
|
29
|
+
}
|
|
30
|
+
describe('filterTimelineQualities', () => {
|
|
31
|
+
const timeline = timelineOf([
|
|
32
|
+
createQuality('audio', 128000),
|
|
33
|
+
createQuality('audio', 256000),
|
|
34
|
+
createQuality('video', 500000),
|
|
35
|
+
]);
|
|
33
36
|
it('returns the timeline unchanged when the filter is null', () => {
|
|
34
37
|
// A null predicate (e.g. no language preference) is a no-op.
|
|
35
|
-
const result = filterTimelineQualities(null,
|
|
36
|
-
throw new Error('empty');
|
|
37
|
-
}, timeline);
|
|
38
|
+
const result = filterTimelineQualities(null, throwEmpty, timeline);
|
|
38
39
|
expect(result).toBe(timeline);
|
|
39
40
|
});
|
|
40
|
-
it('filters qualities by predicate', () => {
|
|
41
|
-
const result = filterTimelineQualities((q) => q.
|
|
42
|
-
throw new Error('empty');
|
|
43
|
-
}, timeline);
|
|
41
|
+
it('filters qualities by predicate while keeping every present stream', () => {
|
|
42
|
+
const result = filterTimelineQualities((q) => q.bandwidth !== 256000, throwEmpty, timeline);
|
|
44
43
|
expect(result.periods[0].qualities.length).toBe(2);
|
|
45
|
-
expect(result.
|
|
44
|
+
expect(contentTypesOf(result)).toEqual(new Set(['audio', 'video']));
|
|
46
45
|
});
|
|
47
46
|
it('preserves minBufferTime', () => {
|
|
48
|
-
const result = filterTimelineQualities(() => true,
|
|
49
|
-
throw new Error('empty');
|
|
50
|
-
}, timeline);
|
|
47
|
+
const result = filterTimelineQualities(() => true, throwEmpty, timeline);
|
|
51
48
|
expect(result.minBufferTime).toBe(2);
|
|
52
49
|
});
|
|
53
50
|
it('throws when filtering removes all qualities', () => {
|
|
54
|
-
expect(() => filterTimelineQualities(() => false, ()
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
expect(() => filterTimelineQualities(() => false, throwEmpty, timeline)).toThrowError('no qualities');
|
|
52
|
+
});
|
|
53
|
+
it('throws when filtering drops a present video stream to zero', () => {
|
|
54
|
+
expect(() => filterTimelineQualities((q) => q.contentType === 'audio', throwEmpty, timeline)).toThrowError('no qualities');
|
|
55
|
+
});
|
|
56
|
+
it('throws when filtering drops a present audio stream to zero', () => {
|
|
57
|
+
expect(() => filterTimelineQualities((q) => q.contentType === 'video', throwEmpty, timeline)).toThrowError('no qualities');
|
|
58
|
+
});
|
|
59
|
+
it('does not throw when losing only an optional text sidecar stream', () => {
|
|
60
|
+
const withText = timelineOf([
|
|
61
|
+
createQuality('audio', 128000),
|
|
62
|
+
createQuality('video', 500000),
|
|
63
|
+
createQuality('text', 1000),
|
|
64
|
+
]);
|
|
65
|
+
const result = filterTimelineQualities((q) => q.contentType !== 'text', throwEmpty, withText);
|
|
66
|
+
expect(contentTypesOf(result)).toEqual(new Set(['audio', 'video']));
|
|
57
67
|
});
|
|
58
68
|
});
|
|
59
69
|
describe('filterTimelineQualitiesAsync', () => {
|
|
60
|
-
const timeline =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
minBufferTime: 2,
|
|
72
|
-
getAdBreaks: () => Promise.resolve([]),
|
|
73
|
-
getDuration: () => Promise.resolve(Infinity),
|
|
74
|
-
};
|
|
75
|
-
it('filters qualities by async predicate', async () => {
|
|
76
|
-
const result = await filterTimelineQualitiesAsync((q) => Promise.resolve(q.contentType === 'audio'), () => {
|
|
77
|
-
throw new Error('empty');
|
|
78
|
-
}, timeline);
|
|
79
|
-
expect(result.periods[0].qualities.length).toBe(1);
|
|
80
|
-
expect(result.periods[0].qualities[0].metadata.contentType).toBe('audio');
|
|
70
|
+
const timeline = timelineOf([
|
|
71
|
+
createQuality('audio', 128000),
|
|
72
|
+
createQuality('audio', 256000),
|
|
73
|
+
createQuality('video', 500000),
|
|
74
|
+
]);
|
|
75
|
+
it('filters qualities by async predicate while keeping every present stream', async () => {
|
|
76
|
+
const result = await filterTimelineQualitiesAsync((q) => Promise.resolve(q.bandwidth !== 256000), throwEmpty, timeline);
|
|
77
|
+
expect(result.periods[0].qualities.length).toBe(2);
|
|
78
|
+
expect(contentTypesOf(result)).toEqual(new Set(['audio', 'video']));
|
|
81
79
|
});
|
|
82
80
|
it('preserves minBufferTime', async () => {
|
|
83
|
-
const result = await filterTimelineQualitiesAsync(() => Promise.resolve(true),
|
|
84
|
-
throw new Error('empty');
|
|
85
|
-
}, timeline);
|
|
81
|
+
const result = await filterTimelineQualitiesAsync(() => Promise.resolve(true), throwEmpty, timeline);
|
|
86
82
|
expect(result.minBufferTime).toBe(2);
|
|
87
83
|
});
|
|
88
84
|
it('throws when filtering removes all qualities', async () => {
|
|
89
|
-
await expectAsync(filterTimelineQualitiesAsync(() => Promise.resolve(false), ()
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
await expectAsync(filterTimelineQualitiesAsync(() => Promise.resolve(false), throwEmpty, timeline)).toBeRejectedWithError('no qualities');
|
|
86
|
+
});
|
|
87
|
+
it('throws when filtering drops a present video stream to zero', async () => {
|
|
88
|
+
await expectAsync(filterTimelineQualitiesAsync((q) => Promise.resolve(q.contentType === 'audio'), throwEmpty, timeline)).toBeRejectedWithError('no qualities');
|
|
89
|
+
});
|
|
90
|
+
it('does not throw when losing only an optional text sidecar stream', async () => {
|
|
91
|
+
const withText = timelineOf([
|
|
92
|
+
createQuality('audio', 128000),
|
|
93
|
+
createQuality('video', 500000),
|
|
94
|
+
createQuality('text', 1000),
|
|
95
|
+
]);
|
|
96
|
+
const result = await filterTimelineQualitiesAsync((q) => Promise.resolve(q.contentType !== 'text'), throwEmpty, withText);
|
|
97
|
+
expect(contentTypesOf(result)).toEqual(new Set(['audio', 'video']));
|
|
92
98
|
});
|
|
93
99
|
});
|
|
@@ -192,6 +192,27 @@ describe('withinAudioSampleRateRange minSampleRate floor', () => {
|
|
|
192
192
|
expect(withinMin(mid, 1, [low, mid])).toBe(true);
|
|
193
193
|
expect(withinMin(low, 0, [low, mid])).toBe(false);
|
|
194
194
|
});
|
|
195
|
+
it('keeps the highest when every rate (of three) is below the floor', () => {
|
|
196
|
+
const low = audioQuality([16_000]);
|
|
197
|
+
const mid = audioQuality([24_000]);
|
|
198
|
+
const high = audioQuality([32_000]);
|
|
199
|
+
expect(withinMin(high, 2, [low, mid, high])).toBe(true);
|
|
200
|
+
expect(withinMin(mid, 1, [low, mid, high])).toBe(false);
|
|
201
|
+
expect(withinMin(low, 0, [low, mid, high])).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
it('keeps the highest below-floor rate when no platform max is reported', () => {
|
|
204
|
+
// No AudioContext rate, so the floor is the only bound; with every rate
|
|
205
|
+
// below it, the highest is kept as the fallback.
|
|
206
|
+
capabilities.sampleRate = null;
|
|
207
|
+
const low = audioQuality([16_000]);
|
|
208
|
+
const high = audioQuality([32_000]);
|
|
209
|
+
expect(withinMin(high, 1, [low, high])).toBe(true);
|
|
210
|
+
expect(withinMin(low, 0, [low, high])).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
it('keeps a single audio rendition below the floor', () => {
|
|
213
|
+
const only = audioQuality([24_000]);
|
|
214
|
+
expect(withinMin(only, 0, [only])).toBe(true);
|
|
215
|
+
});
|
|
195
216
|
it('ignores non-audio qualities in the floor fallback', () => {
|
|
196
217
|
const audio = audioQuality([24_000]); // below the floor
|
|
197
218
|
const video = createEmptyMediaQualityMetadata();
|
|
@@ -202,3 +223,57 @@ describe('withinAudioSampleRateRange minSampleRate floor', () => {
|
|
|
202
223
|
expect(withinMin(video, 1, [audio, video])).toBe(true);
|
|
203
224
|
});
|
|
204
225
|
});
|
|
226
|
+
// The floor and ceiling are soft together: when renditions straddle an empty
|
|
227
|
+
// band (some below the floor, some above the ceiling, none in between) neither
|
|
228
|
+
// bound alone must strand audio.
|
|
229
|
+
describe('withinAudioSampleRateRange straddling an empty band', () => {
|
|
230
|
+
let capabilities;
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
// The soft ceiling is a non-Firefox behavior; Firefox's 48kHz cap is
|
|
233
|
+
// hard (covered above), so pin a non-Firefox UA to exercise the fallback.
|
|
234
|
+
setUserAgent('Chrome');
|
|
235
|
+
capabilities = new MockCapabilities();
|
|
236
|
+
// Ceiling at 48kHz; floor at 44.1kHz → acceptable band is [44.1k, 48k].
|
|
237
|
+
capabilities.sampleRate = 48_000;
|
|
238
|
+
});
|
|
239
|
+
function audioQuality(rate) {
|
|
240
|
+
const quality = createEmptyMediaQualityMetadata();
|
|
241
|
+
quality.contentType = 'audio';
|
|
242
|
+
quality.audioSamplingRate = rate;
|
|
243
|
+
return quality;
|
|
244
|
+
}
|
|
245
|
+
function within(metadata, index, array) {
|
|
246
|
+
return withinAudioSampleRateRange({ capabilities, minSampleRate: 44_100 }, metadata, index, array);
|
|
247
|
+
}
|
|
248
|
+
it('keeps the highest decodable rate when one is below the floor and one above the ceiling', () => {
|
|
249
|
+
const low = audioQuality([22_050]); // below the 44.1kHz floor
|
|
250
|
+
const high = audioQuality([96_000]); // above the 48kHz ceiling
|
|
251
|
+
// Empty band → the below-floor rate is decodable and kept; the
|
|
252
|
+
// above-ceiling rate is dropped, so audio is never stranded.
|
|
253
|
+
expect(within(low, 0, [low, high])).toBe(true);
|
|
254
|
+
expect(within(high, 1, [low, high])).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
it('keeps the below-floor rate when the ceiling matches the output device rate', () => {
|
|
257
|
+
// Output device runs at 44.1kHz, so a 48kHz rendition is above the
|
|
258
|
+
// ceiling while a 22.05kHz rendition is below the floor.
|
|
259
|
+
capabilities.sampleRate = 44_100;
|
|
260
|
+
const low = audioQuality([22_050]);
|
|
261
|
+
const high = audioQuality([48_000]);
|
|
262
|
+
expect(within(low, 0, [low, high])).toBe(true);
|
|
263
|
+
expect(within(high, 1, [low, high])).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
it('still prefers an in-band rate over the straddle fallback', () => {
|
|
266
|
+
const low = audioQuality([22_050]); // below floor
|
|
267
|
+
const mid = audioQuality([44_100]); // in band
|
|
268
|
+
const high = audioQuality([96_000]); // above ceiling
|
|
269
|
+
expect(within(mid, 1, [low, mid, high])).toBe(true);
|
|
270
|
+
expect(within(low, 0, [low, mid, high])).toBe(false);
|
|
271
|
+
expect(within(high, 2, [low, mid, high])).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
it('keeps the lowest when every rate is above the ceiling', () => {
|
|
274
|
+
const low = audioQuality([88_200]);
|
|
275
|
+
const high = audioQuality([96_000]);
|
|
276
|
+
expect(within(low, 0, [low, high])).toBe(true);
|
|
277
|
+
expect(within(high, 1, [low, high])).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|