@amazon/vinyl 2.0.4 → 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.
Files changed (167) hide show
  1. package/dist/index.cjs +603 -317
  2. package/dist/index.cjs.map +4 -4
  3. package/dist/index.js +622 -329
  4. package/dist/index.js.map +4 -4
  5. package/dist/test/benchmark.es5.js +8 -5
  6. package/dist/test/benchmark.js +228 -0
  7. package/dist/test/benchmark.js.map +4 -4
  8. package/dist/test/integ.es5.js +182 -156
  9. package/dist/test/integ.js +1853 -796
  10. package/dist/test/integ.js.map +4 -4
  11. package/dist/types/current/index.d.ts +4 -0
  12. package/dist/types/current/index.d.ts.map +1 -1
  13. package/dist/types/current/index.js +4 -0
  14. package/dist/types/current/streaming/AudioOptions.d.ts +39 -0
  15. package/dist/types/current/streaming/AudioOptions.d.ts.map +1 -0
  16. package/dist/types/current/streaming/AudioOptions.js +15 -0
  17. package/dist/types/current/streaming/createDefaultMediaTimelineTransformer.d.ts +2 -2
  18. package/dist/types/current/streaming/createDefaultMediaTimelineTransformer.d.ts.map +1 -1
  19. package/dist/types/current/streaming/createDefaultMediaTimelineTransformer.js +5 -5
  20. package/dist/types/current/text/HtmlTextTrackRenderer.d.ts +60 -0
  21. package/dist/types/current/text/HtmlTextTrackRenderer.d.ts.map +1 -0
  22. package/dist/types/current/text/HtmlTextTrackRenderer.js +231 -0
  23. package/dist/types/current/text/SidecarTextTrackController.d.ts +65 -53
  24. package/dist/types/current/text/SidecarTextTrackController.d.ts.map +1 -1
  25. package/dist/types/current/text/SidecarTextTrackController.js +167 -154
  26. package/dist/types/current/text/SidecarTextTrackLoader.d.ts +5 -0
  27. package/dist/types/current/text/SidecarTextTrackLoader.d.ts.map +1 -1
  28. package/dist/types/current/text/SidecarTextTrackLoader.js +13 -7
  29. package/dist/types/current/text/TextTrack.d.ts +74 -20
  30. package/dist/types/current/text/TextTrack.d.ts.map +1 -1
  31. package/dist/types/current/text/TextTrack.js +20 -0
  32. package/dist/types/current/text/TextTrackRenderer.d.ts +24 -0
  33. package/dist/types/current/text/TextTrackRenderer.d.ts.map +1 -0
  34. package/dist/types/current/text/TextTrackRenderer.js +5 -0
  35. package/dist/types/current/text/mediaTextTrackProvider.d.ts +34 -0
  36. package/dist/types/current/text/mediaTextTrackProvider.d.ts.map +1 -0
  37. package/dist/types/current/text/mediaTextTrackProvider.js +42 -0
  38. package/dist/types/current/text/parseWebVtt.d.ts +16 -2
  39. package/dist/types/current/text/parseWebVtt.d.ts.map +1 -1
  40. package/dist/types/current/text/parseWebVtt.js +176 -48
  41. package/dist/types/current/text/pickPreferredTextTrack.d.ts +2 -1
  42. package/dist/types/current/text/pickPreferredTextTrack.d.ts.map +1 -1
  43. package/dist/types/current/text/pickPreferredTextTrack.js +6 -3
  44. package/dist/types/current/track/Track.d.ts +1 -1
  45. package/dist/types/current/track/dash/createDashFactories.d.ts +21 -10
  46. package/dist/types/current/track/dash/createDashFactories.d.ts.map +1 -1
  47. package/dist/types/current/track/dash/createDashFactories.js +9 -21
  48. package/dist/types/current/track/hls/createHlsFactories.d.ts +21 -10
  49. package/dist/types/current/track/hls/createHlsFactories.d.ts.map +1 -1
  50. package/dist/types/current/track/hls/createHlsFactories.js +9 -21
  51. package/dist/types/current/track/mse/MseTrack.d.ts +1 -2
  52. package/dist/types/current/track/mse/MseTrack.d.ts.map +1 -1
  53. package/dist/types/current/track/mse/MseTrack.js +3 -7
  54. package/dist/types/current/tsconfig.tsbuildinfo +1 -1
  55. package/dist/types/current/vinyl/VinylOptions.d.ts +18 -40
  56. package/dist/types/current/vinyl/VinylOptions.d.ts.map +1 -1
  57. package/dist/types/current/vinyl/VinylOptions.js +12 -11
  58. package/dist/types/current/vinyl/VinylPlayer.d.ts +0 -6
  59. package/dist/types/current/vinyl/VinylPlayer.d.ts.map +1 -1
  60. package/dist/types/current/vinyl/VinylPlayer.js +2 -23
  61. package/dist/types/current/vinyl/createVinylFactories.d.ts +32 -18
  62. package/dist/types/current/vinyl/createVinylFactories.d.ts.map +1 -1
  63. package/dist/types/current/vinyl/createVinylFactories.js +6 -0
  64. package/dist/types/ts3.7/index.d.ts +4 -0
  65. package/dist/types/ts3.7/streaming/AudioOptions.d.ts +39 -0
  66. package/dist/types/ts3.7/streaming/createDefaultMediaTimelineTransformer.d.ts +2 -2
  67. package/dist/types/ts3.7/text/HtmlTextTrackRenderer.d.ts +60 -0
  68. package/dist/types/ts3.7/text/SidecarTextTrackController.d.ts +65 -53
  69. package/dist/types/ts3.7/text/SidecarTextTrackLoader.d.ts +5 -0
  70. package/dist/types/ts3.7/text/TextTrack.d.ts +74 -20
  71. package/dist/types/ts3.7/text/TextTrackRenderer.d.ts +24 -0
  72. package/dist/types/ts3.7/text/mediaTextTrackProvider.d.ts +34 -0
  73. package/dist/types/ts3.7/text/parseWebVtt.d.ts +16 -2
  74. package/dist/types/ts3.7/text/pickPreferredTextTrack.d.ts +2 -1
  75. package/dist/types/ts3.7/track/Track.d.ts +1 -1
  76. package/dist/types/ts3.7/track/dash/createDashFactories.d.ts +21 -10
  77. package/dist/types/ts3.7/track/hls/createHlsFactories.d.ts +21 -10
  78. package/dist/types/ts3.7/track/mse/MseTrack.d.ts +1 -2
  79. package/dist/types/ts3.7/vinyl/VinylOptions.d.ts +18 -40
  80. package/dist/types/ts3.7/vinyl/VinylPlayer.d.ts +0 -6
  81. package/dist/types/ts3.7/vinyl/createVinylFactories.d.ts +32 -18
  82. package/dist/types/ts4.1/index.d.ts +4 -0
  83. package/dist/types/ts4.1/streaming/AudioOptions.d.ts +39 -0
  84. package/dist/types/ts4.1/streaming/createDefaultMediaTimelineTransformer.d.ts +2 -2
  85. package/dist/types/ts4.1/text/HtmlTextTrackRenderer.d.ts +60 -0
  86. package/dist/types/ts4.1/text/SidecarTextTrackController.d.ts +65 -53
  87. package/dist/types/ts4.1/text/SidecarTextTrackLoader.d.ts +5 -0
  88. package/dist/types/ts4.1/text/TextTrack.d.ts +74 -20
  89. package/dist/types/ts4.1/text/TextTrackRenderer.d.ts +24 -0
  90. package/dist/types/ts4.1/text/mediaTextTrackProvider.d.ts +34 -0
  91. package/dist/types/ts4.1/text/parseWebVtt.d.ts +16 -2
  92. package/dist/types/ts4.1/text/pickPreferredTextTrack.d.ts +2 -1
  93. package/dist/types/ts4.1/track/Track.d.ts +1 -1
  94. package/dist/types/ts4.1/track/dash/createDashFactories.d.ts +21 -10
  95. package/dist/types/ts4.1/track/hls/createHlsFactories.d.ts +21 -10
  96. package/dist/types/ts4.1/track/mse/MseTrack.d.ts +1 -2
  97. package/dist/types/ts4.1/vinyl/VinylOptions.d.ts +18 -40
  98. package/dist/types/ts4.1/vinyl/VinylPlayer.d.ts +0 -6
  99. package/dist/types/ts4.1/vinyl/createVinylFactories.d.ts +32 -18
  100. package/dist/types/ts4.7/index.d.ts +4 -0
  101. package/dist/types/ts4.7/streaming/AudioOptions.d.ts +39 -0
  102. package/dist/types/ts4.7/streaming/createDefaultMediaTimelineTransformer.d.ts +2 -2
  103. package/dist/types/ts4.7/text/HtmlTextTrackRenderer.d.ts +60 -0
  104. package/dist/types/ts4.7/text/SidecarTextTrackController.d.ts +65 -53
  105. package/dist/types/ts4.7/text/SidecarTextTrackLoader.d.ts +5 -0
  106. package/dist/types/ts4.7/text/TextTrack.d.ts +74 -20
  107. package/dist/types/ts4.7/text/TextTrackRenderer.d.ts +24 -0
  108. package/dist/types/ts4.7/text/mediaTextTrackProvider.d.ts +34 -0
  109. package/dist/types/ts4.7/text/parseWebVtt.d.ts +16 -2
  110. package/dist/types/ts4.7/text/pickPreferredTextTrack.d.ts +2 -1
  111. package/dist/types/ts4.7/track/Track.d.ts +1 -1
  112. package/dist/types/ts4.7/track/dash/createDashFactories.d.ts +21 -10
  113. package/dist/types/ts4.7/track/hls/createHlsFactories.d.ts +21 -10
  114. package/dist/types/ts4.7/track/mse/MseTrack.d.ts +1 -2
  115. package/dist/types/ts4.7/vinyl/VinylOptions.d.ts +18 -40
  116. package/dist/types/ts4.7/vinyl/VinylPlayer.d.ts +0 -6
  117. package/dist/types/ts4.7/vinyl/createVinylFactories.d.ts +32 -18
  118. package/dist/unit/types/current/index.d.ts +2 -0
  119. package/dist/unit/types/current/index.d.ts.map +1 -1
  120. package/dist/unit/types/current/index.js +2 -0
  121. package/dist/unit/types/current/streaming/MseTrack.test.js +13 -7
  122. package/dist/unit/types/current/streaming/createDefaultMediaTimelineTransformer.test.js +8 -4
  123. package/dist/unit/types/current/text/HtmlTextTrackRenderer.test.d.ts +2 -0
  124. package/dist/unit/types/current/text/HtmlTextTrackRenderer.test.d.ts.map +1 -0
  125. package/dist/unit/types/current/text/HtmlTextTrackRenderer.test.js +231 -0
  126. package/dist/unit/types/current/text/SidecarTextTrackController.test.js +432 -333
  127. package/dist/unit/types/current/text/SidecarTextTrackLoader.test.js +29 -0
  128. package/dist/unit/types/current/text/mediaTextTrackProvider.test.d.ts +2 -0
  129. package/dist/unit/types/current/text/mediaTextTrackProvider.test.d.ts.map +1 -0
  130. package/dist/unit/types/current/text/mediaTextTrackProvider.test.js +86 -0
  131. package/dist/unit/types/current/text/parseWebVtt.test.js +187 -6
  132. package/dist/unit/types/current/text/pickPreferredTextTrack.test.js +12 -1
  133. package/dist/unit/types/current/track/dash/createDashFactories.test.js +1 -2
  134. package/dist/unit/types/current/track/hls/buildHlsMediaTimeline.test.js +1 -2
  135. package/dist/unit/types/current/track/hls/createHlsFactories.test.js +1 -2
  136. package/dist/unit/types/current/tsconfig.tsbuildinfo +1 -1
  137. package/dist/unit/types/current/vinyl/VinylPlayerTextTracks.test.js +102 -177
  138. package/dist/unit/types/current/vinyl/createVinylFactories.test.js +2 -0
  139. package/dist/vinylTestUtil/index.js +14 -8
  140. package/dist/vinylTestUtil/index.js.map +2 -2
  141. package/dist/vinylTestUtil/types/current/mock/player/MockVinylPlayerDependencies.d.ts +3 -2
  142. package/dist/vinylTestUtil/types/current/mock/player/MockVinylPlayerDependencies.d.ts.map +1 -1
  143. package/dist/vinylTestUtil/types/current/mock/player/MockVinylPlayerDependencies.js +5 -2
  144. package/dist/vinylTestUtil/types/current/mock/text/MockTextTrackController.d.ts +3 -3
  145. package/dist/vinylTestUtil/types/current/mock/text/MockTextTrackController.d.ts.map +1 -1
  146. package/dist/vinylTestUtil/types/current/mock/text/MockTextTrackController.js +3 -3
  147. package/dist/vinylTestUtil/types/current/mock/track/dash/createMockDashDependencies.d.ts +1 -2
  148. package/dist/vinylTestUtil/types/current/mock/track/dash/createMockDashDependencies.d.ts.map +1 -1
  149. package/dist/vinylTestUtil/types/current/mock/track/dash/createMockDashDependencies.js +3 -2
  150. package/dist/vinylTestUtil/types/current/mock/track/hls/createMockHlsDependencies.d.ts +1 -2
  151. package/dist/vinylTestUtil/types/current/mock/track/hls/createMockHlsDependencies.d.ts.map +1 -1
  152. package/dist/vinylTestUtil/types/current/mock/track/hls/createMockHlsDependencies.js +3 -2
  153. package/dist/vinylTestUtil/types/current/tsconfig.tsbuildinfo +1 -1
  154. package/dist/vinylTestUtil/types/ts3.7/mock/player/MockVinylPlayerDependencies.d.ts +3 -2
  155. package/dist/vinylTestUtil/types/ts3.7/mock/text/MockTextTrackController.d.ts +3 -3
  156. package/dist/vinylTestUtil/types/ts3.7/mock/track/dash/createMockDashDependencies.d.ts +1 -2
  157. package/dist/vinylTestUtil/types/ts3.7/mock/track/hls/createMockHlsDependencies.d.ts +1 -2
  158. package/dist/vinylTestUtil/types/ts4.1/mock/player/MockVinylPlayerDependencies.d.ts +3 -2
  159. package/dist/vinylTestUtil/types/ts4.1/mock/text/MockTextTrackController.d.ts +3 -3
  160. package/dist/vinylTestUtil/types/ts4.1/mock/track/dash/createMockDashDependencies.d.ts +1 -2
  161. package/dist/vinylTestUtil/types/ts4.1/mock/track/hls/createMockHlsDependencies.d.ts +1 -2
  162. package/dist/vinylTestUtil/types/ts4.7/mock/player/MockVinylPlayerDependencies.d.ts +3 -2
  163. package/dist/vinylTestUtil/types/ts4.7/mock/text/MockTextTrackController.d.ts +3 -3
  164. package/dist/vinylTestUtil/types/ts4.7/mock/track/dash/createMockDashDependencies.d.ts +1 -2
  165. package/dist/vinylTestUtil/types/ts4.7/mock/track/hls/createMockHlsDependencies.d.ts +1 -2
  166. package/docs/USAGE.md +82 -31
  167. package/package.json +9 -9
@@ -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 { SidecarTextTrackController } from '@amazon/vinyl';
5
+ import { resolveSelectedTrack, SidecarTextTrackController, } from '@amazon/vinyl';
6
6
  import { requesterWithRetryRef } from '@amazon/vinyl-util';
7
7
  import { flushPromises } from '@amazon/vinyl-util/browserTestUtil';
8
8
  import { MockRequester } from '@amazon/vinyl-util/testUtil';
@@ -20,30 +20,64 @@ async function flushUntil(predicate) {
20
20
  for (let i = 0; i < 100 && !predicate(); i++)
21
21
  await flushPromises();
22
22
  }
23
- class FakeMediaElement {
24
- addTextTrack = jasmine
25
- .createSpy('addTextTrack')
23
+ /**
24
+ * A memoized fake provider mirroring {@link MediaTextTrackProvider}: one track
25
+ * per `(kind,label,language)` key, so the number of distinct tracks reveals
26
+ * whether the controller accumulates tracks or reuses them.
27
+ */
28
+ class FakeTextTrackProvider {
29
+ // Every distinct track created (one per rendition key).
30
+ created = [];
31
+ // The track handed out by the most recent getOrCreate (the active one).
32
+ lastTrack = null;
33
+ cache = new Map();
34
+ getOrCreate = jasmine
35
+ .createSpy('getOrCreate')
26
36
  .and.callFake((kind, label, language) => {
37
+ const key = `${kind}-${label}-${language}`;
38
+ const existing = this.cache.get(key);
39
+ if (existing) {
40
+ this.lastTrack = existing.track;
41
+ return existing;
42
+ }
43
+ const storedCues = [];
27
44
  const track = {
28
45
  kind,
29
46
  label: label ?? '',
30
47
  language: language ?? '',
31
48
  mode: 'disabled',
32
- cues: [],
49
+ storedCues,
50
+ // Real TextTrack.cues returns null when mode is 'disabled'.
51
+ get cues() {
52
+ return this.mode === 'disabled' ? null : storedCues;
53
+ },
33
54
  addCue(cue) {
34
- this.cues.push(cue);
55
+ storedCues.push(cue);
35
56
  },
36
57
  removeCue(cue) {
37
- const i = this.cues.indexOf(cue);
58
+ const i = storedCues.indexOf(cue);
38
59
  if (i >= 0)
39
- this.cues.splice(i, 1);
60
+ storedCues.splice(i, 1);
61
+ },
62
+ };
63
+ const ref = {
64
+ track: track,
65
+ clear() {
66
+ // Mirrors the real ref: cues only clear while not disabled.
67
+ if (track.mode === 'disabled')
68
+ return;
69
+ storedCues.length = 0;
40
70
  },
41
71
  };
72
+ this.created.push(track);
73
+ this.cache.set(key, ref);
42
74
  this.lastTrack = track;
43
- return track;
75
+ return ref;
44
76
  });
45
- lastTrack = null;
46
- currentTime = 0;
77
+ /** Tracks currently rendering captions (showing with at least one cue). */
78
+ showingWithCues() {
79
+ return this.created.filter((t) => t.mode === 'showing' && (t.cues?.length ?? 0) > 0);
80
+ }
47
81
  }
48
82
  function makeTrack(overrides = {}) {
49
83
  return {
@@ -60,18 +94,45 @@ function makeTrack(overrides = {}) {
60
94
  };
61
95
  }
62
96
  describe('SidecarTextTrackController', () => {
63
- let media;
64
- let controller;
65
97
  let requester;
66
98
  let originalCue;
99
+ const disposers = [];
100
+ function build(opts) {
101
+ const provider = new FakeTextTrackProvider();
102
+ const textTracks$ = data(Promise.resolve([]));
103
+ const options$ = data(opts?.options ?? { selection: {} });
104
+ const playback = { currentTime: 0 };
105
+ const controller = new SidecarTextTrackController({
106
+ textTrackProvider: provider,
107
+ textTracks: textTracks$,
108
+ options: options$,
109
+ playbackController: playback,
110
+ requestInit: opts?.requestInit,
111
+ textTrackRenderer: opts?.textTrackRenderer,
112
+ });
113
+ // The owning media track activates the controller once current; do the
114
+ // same so cues render (unless a test wants to control activation).
115
+ if (opts?.activate !== false)
116
+ controller.activate();
117
+ disposers.push(() => controller.dispose());
118
+ return {
119
+ controller,
120
+ provider,
121
+ options$,
122
+ textTracks$,
123
+ playback,
124
+ async setTracks(tracks) {
125
+ textTracks$.value = Promise.resolve(tracks);
126
+ await flushPromises();
127
+ },
128
+ select(selection) {
129
+ options$.value = { ...options$.value, selection };
130
+ },
131
+ };
132
+ }
67
133
  beforeEach(() => {
68
134
  requester = new MockRequester();
69
135
  requesterWithRetryRef.set(() => requester);
70
- media = new FakeMediaElement();
71
- controller = new SidecarTextTrackController({
72
- media: media,
73
- cueStyle: data({ size: 90 }),
74
- });
75
136
  originalCue = globalThis.VTTCue;
76
137
  globalThis.VTTCue = function VTTCue(startTime, endTime, text) {
77
138
  this.startTime = startTime;
@@ -82,123 +143,123 @@ describe('SidecarTextTrackController', () => {
82
143
  };
83
144
  });
84
145
  afterEach(() => {
85
- controller.dispose();
146
+ for (const dispose of disposers.splice(0))
147
+ dispose();
86
148
  globalThis.VTTCue = originalCue;
87
149
  });
88
150
  function respondVtt(body) {
89
151
  requester.request.and.resolveTo(new Response(body));
90
152
  }
91
153
  it('starts with no tracks and no active selection', () => {
154
+ const { controller } = build();
92
155
  expect(controller.textTracks).toEqual([]);
93
156
  expect(controller.activeTextTrack).toBeNull();
94
157
  });
95
- describe('forced auto-selection', () => {
96
- it('auto-selects a forced track on discovery', () => {
97
- controller.setTextTracks([
98
- makeTrack({ id: 'full', label: 'English' }),
99
- makeTrack({ id: 'forced', label: 'Forced', forced: true }),
100
- ]);
101
- expect(controller.activeTextTrack?.id).toBe('forced');
158
+ describe('resolveSelectedTrack', () => {
159
+ const full = makeTrack({ id: 'full', language: 'en', forced: false });
160
+ const forced = makeTrack({ id: 'forced', language: 'en', forced: true });
161
+ const captions = makeTrack({
162
+ id: 'cc',
163
+ language: 'en',
164
+ forced: false,
165
+ kind: 'captions',
102
166
  });
103
- it('prefers the forced track matching the default language', () => {
104
- controller.setTextTracks([
105
- makeTrack({ id: 'en-f', language: 'en', forced: true }),
106
- makeTrack({
107
- id: 'es-f',
108
- language: 'es',
109
- forced: true,
110
- default: true,
111
- }),
112
- ]);
113
- expect(controller.activeTextTrack?.id).toBe('es-f');
167
+ it("renders nothing when enabled is 'off' (even with an id)", () => {
168
+ expect(resolveSelectedTrack([full, forced], {
169
+ enabled: 'off',
170
+ selection: { id: 'full' },
171
+ })).toBeNull();
114
172
  });
115
- it('does not auto-select when there is no forced track', () => {
116
- controller.setTextTracks([makeTrack({ id: 'full' })]);
117
- expect(controller.activeTextTrack).toBeNull();
173
+ it("enabled 'on' selects the full track", () => {
174
+ expect(resolveSelectedTrack([full, forced], {
175
+ enabled: 'on',
176
+ selection: { language: 'en' },
177
+ })?.id).toBe('full');
118
178
  });
119
- it('does not auto-select after the app made an explicit choice', () => {
120
- // App explicitly turned captions off (null) before tracks arrive.
121
- controller.setActiveTextTrack(null);
122
- controller.setTextTracks([
123
- makeTrack({ id: 'forced', forced: true }),
124
- ]);
125
- expect(controller.activeTextTrack).toBeNull();
179
+ it("enabled 'forced' selects the forced track", () => {
180
+ expect(resolveSelectedTrack([full, forced], {
181
+ enabled: 'forced',
182
+ selection: { language: 'en' },
183
+ })?.id).toBe('forced');
126
184
  });
127
- it('does not override an explicit selection with a forced track', () => {
128
- controller.setTextTracks([makeTrack({ id: 'full' })]);
129
- controller.setActiveTextTrack('full');
130
- // A later list reveals a forced track; the explicit choice stands.
131
- controller.setTextTracks([
132
- makeTrack({ id: 'full' }),
133
- makeTrack({ id: 'forced', forced: true }),
134
- ]);
135
- expect(controller.activeTextTrack?.id).toBe('full');
185
+ it('selection.forced overrides the enabled mode', () => {
186
+ expect(resolveSelectedTrack([full, forced], {
187
+ enabled: 'on',
188
+ selection: { language: 'en', forced: true },
189
+ })?.id).toBe('forced');
190
+ });
191
+ it('an explicit id wins over other criteria', () => {
192
+ expect(resolveSelectedTrack([full, forced], {
193
+ selection: { id: 'forced', language: 'en', forced: false },
194
+ })?.id).toBe('forced');
195
+ });
196
+ it('unknown id selects nothing', () => {
197
+ expect(resolveSelectedTrack([full], { selection: { id: 'nope' } })).toBeNull();
198
+ });
199
+ it('forced defaults to true (only forced tracks)', () => {
200
+ expect(resolveSelectedTrack([full, forced], {
201
+ selection: { language: 'en' },
202
+ })?.id).toBe('forced');
203
+ });
204
+ it('forced:false selects the full track', () => {
205
+ expect(resolveSelectedTrack([full, forced], {
206
+ selection: { language: 'en', forced: false },
207
+ })?.id).toBe('full');
208
+ });
209
+ it('kind restricts the candidate pool', () => {
210
+ expect(resolveSelectedTrack([full, captions], {
211
+ selection: {
212
+ language: 'en',
213
+ forced: false,
214
+ kind: 'captions',
215
+ },
216
+ })?.id).toBe('cc');
217
+ });
218
+ it('falls back to navigator.languages when no language is given', () => {
219
+ // Node exposes navigator.languages === ['en-US'], relating to 'en'.
220
+ expect(resolveSelectedTrack([full], { selection: { forced: false } })
221
+ ?.id).toBe('full');
222
+ });
223
+ it('an ordered language list prefers earlier entries', () => {
224
+ const es = makeTrack({ id: 'es', language: 'es', forced: false });
225
+ expect(resolveSelectedTrack([full, es], {
226
+ selection: { language: ['es', 'en'], forced: false },
227
+ })?.id).toBe('es');
136
228
  });
137
- });
138
- it('emits textTracksChange when discovered list changes', () => {
139
- const handler = jasmine.createSpy('textTracksChange');
140
- controller.on('textTracksChange', handler);
141
- const t = makeTrack();
142
- controller.setTextTracks([t]);
143
- expect(handler).toHaveBeenCalledTimes(1);
144
- // Same list contents → no event.
145
- controller.setTextTracks([t]);
146
- expect(handler).toHaveBeenCalledTimes(1);
147
- });
148
- it('detects changed track properties', () => {
149
- const handler = jasmine.createSpy();
150
- controller.on('textTracksChange', handler);
151
- controller.setTextTracks([makeTrack({ label: 'A' })]);
152
- controller.setTextTracks([makeTrack({ label: 'B' })]);
153
- expect(handler).toHaveBeenCalledTimes(2);
154
229
  });
155
230
  it('selecting a known id loads cues and creates a TextTrack', async () => {
156
231
  respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nfirst`);
157
- const t = makeTrack();
158
- controller.setTextTracks([t]);
232
+ const h = build({ options: { selection: { id: 'a' } } });
159
233
  const activeChange = jasmine.createSpy();
160
- controller.on('activeTextTrackChange', activeChange);
161
- controller.setActiveTextTrack(t.id);
162
- expect(controller.activeTextTrack).toBe(t);
234
+ h.controller.on('activeTextTrackChange', activeChange);
235
+ await h.setTracks([makeTrack()]);
236
+ expect(h.controller.activeTextTrack?.id).toBe('a');
163
237
  expect(activeChange).toHaveBeenCalledTimes(1);
164
- // Wait for the load chain (fetch -> text -> parse -> onCues) to resolve.
165
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
166
- expect(media.addTextTrack).toHaveBeenCalledWith('subtitles', 'English', 'en');
167
- expect(media.lastTrack?.mode).toBe('showing');
168
- expect(media.lastTrack?.cues.length).toBe(1);
169
- // Cue box is inset from the default full width so it doesn't clip in
170
- // fullscreen.
171
- const cue = media.lastTrack?.cues[0];
172
- expect(cue.size).toBe(90);
173
- });
174
- describe('cueStyle', () => {
175
- async function loadFirstCue(ctrl) {
176
- respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nfirst`);
177
- const t = makeTrack();
178
- ctrl.setTextTracks([t]);
179
- ctrl.setActiveTextTrack(t.id);
180
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
181
- return media.lastTrack?.cues[0];
182
- }
183
- it('does not style cues when cueStyle is null', async () => {
184
- const ctrl = new SidecarTextTrackController({
185
- media: media,
186
- cueStyle: data(null),
187
- });
188
- const cue = await loadFirstCue(ctrl);
189
- expect(cue.size).toBe(100); // stub default, unchanged
190
- ctrl.dispose();
238
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
239
+ expect(h.provider.getOrCreate).toHaveBeenCalledWith('subtitles', 'English', 'en');
240
+ expect(h.provider.lastTrack?.mode).toBe('showing');
241
+ expect(h.provider.lastTrack?.cues?.length).toBe(1);
242
+ });
243
+ describe('authored cue settings', () => {
244
+ it("applies the cue's own parsed settings to the VTTCue", async () => {
245
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000 align:left size:80%\nx`);
246
+ const h = build({ options: { selection: { id: 'a' } } });
247
+ await h.setTracks([makeTrack()]);
248
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
249
+ const cue = h.provider.lastTrack?.cues?.[0];
250
+ expect(cue.align).toBe('left');
251
+ expect(cue.size).toBe(80);
191
252
  });
192
- it('does not style cues when no cueStyle dep is provided', async () => {
193
- const ctrl = new SidecarTextTrackController({
194
- media: media,
195
- });
196
- const cue = await loadFirstCue(ctrl);
253
+ it('leaves the cue at defaults when it declares no settings', async () => {
254
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nx`);
255
+ const h = build({ options: { selection: { id: 'a' } } });
256
+ await h.setTracks([makeTrack()]);
257
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
258
+ // The VTTCue stub defaults size to 100; unset settings don't touch it.
259
+ const cue = h.provider.lastTrack?.cues?.[0];
197
260
  expect(cue.size).toBe(100);
198
- ctrl.dispose();
199
261
  });
200
- it('does not style non-VTTCue cues', async () => {
201
- // VTTCue-less platform: cues are plain TextTrackCues, not styleable.
262
+ it('does not apply settings to non-VTTCue cues', async () => {
202
263
  ;
203
264
  globalThis.VTTCue = undefined;
204
265
  globalThis.TextTrackCue = function TextTrackCue(start, end, text) {
@@ -208,23 +269,106 @@ describe('SidecarTextTrackController', () => {
208
269
  this.id = '';
209
270
  this.size = 100;
210
271
  };
211
- const ctrl = new SidecarTextTrackController({
212
- media: media,
213
- cueStyle: data({ size: 80 }),
214
- });
215
- const cue = await loadFirstCue(ctrl);
216
- expect(cue.size).toBe(100); // not a VTTCue → not styled
217
- ctrl.dispose();
272
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000 size:80%\nignored`);
273
+ const h = build({ options: { selection: { id: 'a' } } });
274
+ await h.setTracks([makeTrack()]);
275
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
276
+ const cue = h.provider.lastTrack?.cues?.[0];
277
+ expect(cue.size).toBe(100) // not a VTTCue → never styled
278
+ ;
218
279
  globalThis.TextTrackCue = undefined;
219
280
  });
220
281
  });
282
+ describe('text track renderer', () => {
283
+ function fakeRenderer() {
284
+ return {
285
+ setTextTrack: jasmine.createSpy('setTextTrack'),
286
+ setStyles: jasmine.createSpy('setStyles'),
287
+ };
288
+ }
289
+ it("keeps the DOM track 'hidden' and hands it to the renderer", async () => {
290
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nx`);
291
+ const renderer = fakeRenderer();
292
+ const h = build({
293
+ options: { selection: { id: 'a' } },
294
+ textTrackRenderer: renderer,
295
+ });
296
+ await h.setTracks([makeTrack()]);
297
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
298
+ expect(h.provider.lastTrack?.mode).toBe('hidden');
299
+ expect(renderer.setTextTrack).toHaveBeenCalledWith(h.provider.lastTrack);
300
+ });
301
+ it('detaches the renderer when captions are turned off', async () => {
302
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nx`);
303
+ const renderer = fakeRenderer();
304
+ const h = build({
305
+ options: { selection: { id: 'a' } },
306
+ textTrackRenderer: renderer,
307
+ });
308
+ await h.setTracks([makeTrack()]);
309
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
310
+ renderer.setTextTrack.calls.reset();
311
+ h.options$.value = { enabled: 'off' };
312
+ expect(renderer.setTextTrack).toHaveBeenCalledWith(null);
313
+ });
314
+ it('forwards authored STYLE blocks to the renderer', async () => {
315
+ respondVtt(`WEBVTT\n\nSTYLE\n::cue { color: red }\n\n00:00:01.000 --> 00:00:02.000\nx`);
316
+ const renderer = fakeRenderer();
317
+ const h = build({
318
+ options: { selection: { id: 'a' } },
319
+ textTrackRenderer: renderer,
320
+ });
321
+ await h.setTracks([makeTrack()]);
322
+ await flushUntil(() => renderer.setStyles.calls.count() > 0);
323
+ expect(renderer.setStyles).toHaveBeenCalledWith([
324
+ '::cue { color: red }',
325
+ ]);
326
+ });
327
+ it('ignores styles that arrive after the load is cancelled', async () => {
328
+ let resolveFetch = () => { };
329
+ requester.request.and.returnValue(new Promise((res) => {
330
+ resolveFetch = res;
331
+ }));
332
+ const renderer = fakeRenderer();
333
+ const h = build({
334
+ options: { selection: { id: 'a' } },
335
+ textTrackRenderer: renderer,
336
+ });
337
+ await h.setTracks([makeTrack()]);
338
+ h.options$.value = { enabled: 'off' }; // cancels the load
339
+ renderer.setStyles.calls.reset();
340
+ resolveFetch(new Response(`WEBVTT\n\nSTYLE\n::cue { color: red }\n\n00:00:01.000 --> 00:00:02.000\nx`));
341
+ await flushPromises();
342
+ expect(renderer.setStyles).not.toHaveBeenCalled();
343
+ });
344
+ it('works with a renderer that cannot consume styles', async () => {
345
+ respondVtt(`WEBVTT\n\nSTYLE\n::cue { color: red }\n\n00:00:01.000 --> 00:00:02.000\nx`);
346
+ // A renderer implementing only setTextTrack (setStyles is optional).
347
+ const renderer = {
348
+ setTextTrack: jasmine.createSpy('setTextTrack'),
349
+ };
350
+ const h = build({
351
+ options: { selection: { id: 'a' } },
352
+ textTrackRenderer: renderer,
353
+ });
354
+ await h.setTracks([makeTrack()]);
355
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
356
+ expect(renderer.setTextTrack).toHaveBeenCalled();
357
+ });
358
+ it("renders natively ('showing') when no renderer is injected", async () => {
359
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nx`);
360
+ const h = build({ options: { selection: { id: 'a' } } });
361
+ await h.setTracks([makeTrack()]);
362
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
363
+ expect(h.provider.lastTrack?.mode).toBe('showing');
364
+ });
365
+ });
221
366
  it('preserves cue id when present', async () => {
222
367
  respondVtt(`WEBVTT\n\nidA\n00:00:01.000 --> 00:00:02.000\nfirst`);
223
- const t = makeTrack();
224
- controller.setTextTracks([t]);
225
- controller.setActiveTextTrack(t.id);
226
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
227
- const cue = media.lastTrack?.cues[0];
368
+ const h = build({ options: { selection: { id: 'a' } } });
369
+ await h.setTracks([makeTrack()]);
370
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
371
+ const cue = h.provider.lastTrack?.cues?.[0];
228
372
  expect(cue.id).toBe('idA');
229
373
  });
230
374
  it('dedupes cues that appear in more than one segment', async () => {
@@ -251,55 +395,56 @@ b.vtt
251
395
  return Promise.resolve(new Response(segA));
252
396
  return Promise.resolve(new Response(segB));
253
397
  });
254
- const t = makeTrack({ uri: 'https://x.test/sub.m3u8' });
255
- controller.setTextTracks([t]);
256
- controller.setActiveTextTrack(t.id);
257
- // Two segments + dedup: wait until all three distinct cues have loaded.
258
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) >= 3);
259
- const texts = (media.lastTrack?.cues ?? []).map((c) => c.text);
398
+ const h = build({ options: { selection: { id: 'a' } } });
399
+ await h.setTracks([makeTrack({ uri: 'https://x.test/sub.m3u8' })]);
400
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) >= 3);
401
+ const texts = (h.provider.lastTrack?.cues ?? []).map((c) => c.text);
260
402
  expect(texts).toEqual(['only-a', 'shared', 'only-b']);
261
403
  });
262
- it('selecting an unknown id is a no-op', () => {
404
+ it('selecting an unknown id renders nothing and stays inactive', async () => {
263
405
  const handler = jasmine.createSpy();
264
- controller.on('activeTextTrackChange', handler);
265
- controller.setTextTracks([makeTrack()]);
266
- controller.setActiveTextTrack('unknown');
406
+ const h = build({ options: { selection: { id: 'unknown' } } });
407
+ h.controller.on('activeTextTrackChange', handler);
408
+ await h.setTracks([makeTrack()]);
267
409
  expect(handler).not.toHaveBeenCalled();
268
- expect(controller.activeTextTrack).toBeNull();
410
+ expect(h.controller.activeTextTrack).toBeNull();
411
+ expect(h.provider.getOrCreate).not.toHaveBeenCalled();
269
412
  });
270
- it('selecting null clears the current active track', () => {
413
+ it('disabling clears the current active track', async () => {
271
414
  respondVtt(`WEBVTT\n`);
272
- const t = makeTrack();
273
- controller.setTextTracks([t]);
274
- controller.setActiveTextTrack(t.id);
275
- controller.setActiveTextTrack(null);
276
- expect(controller.activeTextTrack).toBeNull();
415
+ const h = build({ options: { selection: { id: 'a' } } });
416
+ await h.setTracks([makeTrack()]);
417
+ expect(h.controller.activeTextTrack?.id).toBe('a');
418
+ h.options$.value = { enabled: 'off' };
419
+ expect(h.controller.activeTextTrack).toBeNull();
277
420
  });
278
- it('clears active selection when its track is removed', () => {
421
+ it('clears active selection when its track is removed', async () => {
279
422
  respondVtt(`WEBVTT\n`);
423
+ const h = build({ options: { selection: { id: 'a' } } });
280
424
  const t1 = makeTrack({ id: 'a' });
281
425
  const t2 = makeTrack({ id: 'b', uri: 'https://x.test/b.vtt' });
282
- controller.setTextTracks([t1, t2]);
283
- controller.setActiveTextTrack('a');
284
- controller.setTextTracks([t2]);
285
- expect(controller.activeTextTrack).toBeNull();
426
+ await h.setTracks([t1, t2]);
427
+ expect(h.controller.activeTextTrack?.id).toBe('a');
428
+ await h.setTracks([t2]);
429
+ expect(h.controller.activeTextTrack).toBeNull();
286
430
  });
287
- it('keeps active selection when track list updates with same active', () => {
431
+ it('keeps the active selection when the list updates with it still present', async () => {
288
432
  respondVtt(`WEBVTT\n`);
433
+ const h = build({ options: { selection: { id: 'a' } } });
289
434
  const t1 = makeTrack({ id: 'a' });
290
435
  const t2 = makeTrack({ id: 'b' });
291
- controller.setTextTracks([t1]);
292
- controller.setActiveTextTrack('a');
293
- controller.setTextTracks([t1, t2]);
294
- expect(controller.activeTextTrack?.id).toBe('a');
436
+ await h.setTracks([t1]);
437
+ expect(h.controller.activeTextTrack?.id).toBe('a');
438
+ await h.setTracks([t1, t2]);
439
+ expect(h.controller.activeTextTrack?.id).toBe('a');
295
440
  });
296
441
  it('emits textTrackError on fetch failure', async () => {
297
442
  requester.request.and.rejectWith(new Error('boom'));
443
+ const h = build({ options: { selection: { id: 'a' } } });
298
444
  const onError = jasmine.createSpy('textTrackError');
299
- controller.on('textTrackError', onError);
445
+ h.controller.on('textTrackError', onError);
300
446
  const t = makeTrack();
301
- controller.setTextTracks([t]);
302
- controller.setActiveTextTrack(t.id);
447
+ await h.setTracks([t]);
303
448
  await flushUntil(() => onError.calls.count() > 0);
304
449
  expect(onError).toHaveBeenCalledTimes(1);
305
450
  const arg = onError.calls.mostRecent().args[0];
@@ -308,14 +453,12 @@ b.vtt
308
453
  });
309
454
  it('wraps non-Error rejections', async () => {
310
455
  requester.request.and.rejectWith('plain');
456
+ const h = build({ options: { selection: { id: 'a' } } });
311
457
  const onError = jasmine.createSpy();
312
- controller.on('textTrackError', onError);
313
- const t = makeTrack();
314
- controller.setTextTracks([t]);
315
- controller.setActiveTextTrack(t.id);
458
+ h.controller.on('textTrackError', onError);
459
+ await h.setTracks([makeTrack()]);
316
460
  await flushUntil(() => onError.calls.count() > 0);
317
- const arg = onError.calls.mostRecent().args[0];
318
- expect(arg.error.message).toBe('plain');
461
+ expect(onError.calls.mostRecent().args[0].error.message).toBe('plain');
319
462
  });
320
463
  it('drops cues that arrive after the load has been cancelled', async () => {
321
464
  // Delay the fetch by one macrotask so we can cancel between the
@@ -324,18 +467,16 @@ b.vtt
324
467
  requester.request.and.returnValue(new Promise((res) => {
325
468
  resolveFetch = res;
326
469
  }));
327
- const t = makeTrack();
328
- controller.setTextTracks([t]);
329
- controller.setActiveTextTrack(t.id);
330
- controller.setActiveTextTrack(null);
331
- // Now respond — but the abort has already fired.
470
+ const h = build({ options: { selection: { id: 'a' } } });
471
+ await h.setTracks([makeTrack()]);
472
+ const track = h.provider.lastTrack;
473
+ // Turn captions off — aborts the in-flight load.
474
+ h.options$.value = { enabled: 'off' };
332
475
  resolveFetch(new Response(`WEBVTT\n\n00:00:00.500 --> 00:00:01.500\npost`));
333
476
  await flushPromises();
334
- expect(media.lastTrack?.cues.length ?? 0).toBe(0);
477
+ expect(track?.storedCues.length ?? 0).toBe(0);
335
478
  });
336
- it('does not emit textTrackError when load is cancelled by switching tracks', async () => {
337
- // First request hangs until we resolve it. Deactivate the track,
338
- // then resolve with a rejection — the abort should suppress the error.
479
+ it('does not emit textTrackError when a load is cancelled by switching', async () => {
339
480
  let rejectFirst = () => { };
340
481
  let callCount = 0;
341
482
  requester.request.and.callFake(() => new Promise((_, rej) => {
@@ -345,18 +486,16 @@ b.vtt
345
486
  else
346
487
  rej(new Error('unused'));
347
488
  }));
489
+ const h = build({ options: { selection: { id: 'a' } } });
348
490
  const onError = jasmine.createSpy('textTrackError');
349
- controller.on('textTrackError', onError);
350
- const t = makeTrack();
351
- controller.setTextTracks([t]);
352
- controller.setActiveTextTrack(t.id);
353
- // Now cancel by clearing.
354
- controller.setActiveTextTrack(null);
491
+ h.controller.on('textTrackError', onError);
492
+ await h.setTracks([makeTrack()]);
493
+ h.options$.value = { enabled: 'off' };
355
494
  rejectFirst(new Error('would-be error'));
356
495
  await flushPromises();
357
496
  expect(onError).not.toHaveBeenCalled();
358
497
  });
359
- it('switching active track clears the previous DOM cues', async () => {
498
+ it('switching language reuses the per-rendition track and swaps its cues', async () => {
360
499
  let call = 0;
361
500
  requester.request.and.callFake(() => {
362
501
  call++;
@@ -364,98 +503,114 @@ b.vtt
364
503
  ? `WEBVTT\n\n00:00:00.500 --> 00:00:01.500\none`
365
504
  : `WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ntwo`));
366
505
  });
367
- const a = makeTrack({ id: 'a' });
368
- const b = makeTrack({
369
- id: 'b',
370
- uri: 'https://x.test/b.vtt',
371
- label: 'Two',
506
+ const en = makeTrack({ id: 'en', language: 'en', label: 'English' });
507
+ const es = makeTrack({
508
+ id: 'es',
509
+ language: 'es',
510
+ label: 'Espanol',
511
+ uri: 'https://x.test/es.vtt',
372
512
  });
373
- controller.setTextTracks([a, b]);
374
- controller.setActiveTextTrack('a');
375
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
376
- const firstTrack = media.lastTrack;
377
- expect(firstTrack?.cues.length).toBe(1);
378
- controller.setActiveTextTrack('b');
379
- // First track should be cleared synchronously on switch.
380
- expect(firstTrack?.cues.length).toBe(0);
381
- expect(firstTrack?.mode).toBe('disabled');
382
- // The second track loads its own cue.
383
- await flushUntil(() => media.lastTrack !== firstTrack &&
384
- (media.lastTrack?.cues.length ?? 0) > 0);
385
- expect(media.lastTrack?.cues.length).toBe(1);
513
+ const h = build({ options: { selection: { id: 'en' } } });
514
+ await h.setTracks([en, es]);
515
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
516
+ const enTrack = h.provider.lastTrack;
517
+ h.select({ id: 'es' });
518
+ // The outgoing English track is hidden and cleared; a distinct Spanish
519
+ // track (its own rendition) renders instead.
520
+ expect(enTrack?.storedCues.length).toBe(0);
521
+ expect(enTrack?.mode).toBe('disabled');
522
+ await flushUntil(() => h.provider.lastTrack !== enTrack &&
523
+ (h.provider.lastTrack?.cues?.length ?? 0) > 0);
524
+ expect(h.provider.lastTrack).not.toBe(enTrack);
525
+ expect(h.provider.lastTrack?.language).toBe('es');
526
+ expect(h.provider.showingWithCues().length).toBe(1);
527
+ });
528
+ it('cycling languages never accumulates or double-shows cues', async () => {
529
+ requester.request.and.callFake(() => Promise.resolve(new Response(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\ncue`)));
530
+ const langs = ['en', 'es', 'ja'].map((language) => makeTrack({
531
+ id: language,
532
+ language,
533
+ uri: `https://x.test/${language}.vtt`,
534
+ }));
535
+ const h = build({ options: { selection: { id: 'en' } } });
536
+ await h.setTracks(langs);
537
+ for (const id of ['en', 'es', 'ja', 'en']) {
538
+ h.select({ id });
539
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
540
+ expect(h.provider.showingWithCues().length)
541
+ .withContext(`after selecting ${id}`)
542
+ .toBe(1);
543
+ }
544
+ // One track per distinct rendition (3), reused on the repeat of 'en' —
545
+ // never one per activation (which would be 4).
546
+ expect(h.provider.created.length).toBe(3);
386
547
  });
387
- it('suspend clears the DOM track and resume rebuilds it, keeping the selection', async () => {
388
- // Fresh Response per call — resume() re-fetches and a Response body can
389
- // only be read once.
548
+ it('deactivate hides the DOM track and activate rebuilds it, keeping the selection', async () => {
390
549
  requester.request.and.callFake(() => Promise.resolve(new Response(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\none`)));
391
- const t = makeTrack();
392
- controller.setTextTracks([t]);
393
- controller.setActiveTextTrack(t.id);
394
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
395
- const first = media.lastTrack;
396
- expect(first?.mode).toBe('showing');
397
- // Suspend (track deactivated for an ad): DOM track torn down, selection kept.
398
- controller.suspend();
399
- expect(first?.cues.length).toBe(0);
400
- expect(first?.mode).toBe('disabled');
401
- expect(controller.activeTextTrack?.id).toBe(t.id);
402
- // Resume (content reactivated): a fresh DOM track renders the cues again.
403
- controller.resume();
404
- await flushUntil(() => media.lastTrack !== first &&
405
- (media.lastTrack?.cues.length ?? 0) > 0);
406
- expect(media.lastTrack?.mode).toBe('showing');
407
- expect(media.lastTrack?.cues.length).toBe(1);
408
- });
409
- it('resume is a no-op when nothing is active', () => {
410
- controller.resume();
411
- expect(media.addTextTrack).not.toHaveBeenCalled();
412
- });
413
- it('resume is a no-op when already rendering', async () => {
550
+ const h = build({ options: { selection: { id: 'a' } } });
551
+ await h.setTracks([makeTrack()]);
552
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
553
+ const track = h.provider.lastTrack;
554
+ expect(track?.mode).toBe('showing');
555
+ expect(h.controller.active).toBeTrue();
556
+ h.controller.deactivate();
557
+ expect(h.controller.active).toBeFalse();
558
+ expect(track?.storedCues.length).toBe(0);
559
+ expect(track?.mode).toBe('disabled');
560
+ expect(h.controller.activeTextTrack?.id).toBe('a');
561
+ h.controller.activate();
562
+ expect(h.controller.active).toBeTrue();
563
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
564
+ expect(h.provider.lastTrack?.mode).toBe('showing');
565
+ expect(h.provider.lastTrack?.cues?.length).toBe(1);
566
+ });
567
+ it('activate is a no-op when nothing is selected', () => {
568
+ const h = build();
569
+ h.controller.activate();
570
+ expect(h.provider.getOrCreate).not.toHaveBeenCalled();
571
+ });
572
+ it('activate is a no-op when already rendering', async () => {
414
573
  respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\none`);
415
- const t = makeTrack();
416
- controller.setTextTracks([t]);
417
- controller.setActiveTextTrack(t.id);
418
- await flushUntil(() => (media.lastTrack?.cues.length ?? 0) > 0);
419
- media.addTextTrack.calls.reset();
420
- controller.resume();
421
- expect(media.addTextTrack).not.toHaveBeenCalled();
574
+ const h = build({ options: { selection: { id: 'a' } } });
575
+ await h.setTracks([makeTrack()]);
576
+ await flushUntil(() => (h.provider.lastTrack?.cues?.length ?? 0) > 0);
577
+ h.provider.getOrCreate.calls.reset();
578
+ h.controller.activate();
579
+ expect(h.provider.getOrCreate).not.toHaveBeenCalled();
580
+ });
581
+ it('does not render while deactivated even as the selection resolves', async () => {
582
+ respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\none`);
583
+ const h = build({
584
+ options: { selection: { id: 'a' } },
585
+ activate: false,
586
+ });
587
+ h.controller.deactivate();
588
+ await h.setTracks([makeTrack()]);
589
+ // Selection is tracked, but nothing is rendered until activate().
590
+ expect(h.controller.activeTextTrack?.id).toBe('a');
591
+ expect(h.provider.getOrCreate).not.toHaveBeenCalled();
422
592
  });
423
593
  it('skips DOM cue creation when no VTTCue ctor is available', async () => {
424
594
  ;
425
595
  globalThis.VTTCue = undefined;
426
596
  respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nignored`);
427
- const t = makeTrack();
428
- controller.setTextTracks([t]);
429
- controller.setActiveTextTrack(t.id);
430
- await flushPromises();
431
- expect(media.lastTrack?.cues.length).toBe(0);
432
- });
433
- it('can run without a DOM media element', async () => {
434
- const headless = new SidecarTextTrackController({ media: null });
435
- const t = makeTrack();
436
- respondVtt(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nx`);
437
- headless.setTextTracks([t]);
438
- headless.setActiveTextTrack(t.id);
597
+ const h = build({ options: { selection: { id: 'a' } } });
598
+ await h.setTracks([makeTrack()]);
439
599
  await flushPromises();
440
- expect(headless.activeTextTrack).toBe(t);
441
- headless.dispose();
600
+ expect(h.provider.lastTrack?.cues?.length ?? 0).toBe(0);
442
601
  });
443
602
  it('forwards requestInit when configured', async () => {
444
- const customController = new SidecarTextTrackController({
445
- media: media,
603
+ respondVtt(`WEBVTT\n`);
604
+ const h = build({
605
+ options: { selection: { id: 'a' } },
446
606
  requestInit: { credentials: 'include' },
447
607
  });
448
- respondVtt(`WEBVTT\n`);
449
608
  const t = makeTrack();
450
- customController.setTextTracks([t]);
451
- customController.setActiveTextTrack(t.id);
609
+ await h.setTracks([t]);
452
610
  await flushPromises();
453
611
  expect(requester.request).toHaveBeenCalledWith(t.uri, jasmine.objectContaining({ credentials: 'include' }), jasmine.any(Object));
454
- customController.dispose();
455
612
  });
456
613
  it('drives HLS caption segment order from the media playhead', async () => {
457
- // Media at t=12s should cause seg2 (10s..15s) to be fetched first.
458
- media.currentTime = 12;
459
614
  const playlistBody = `#EXTM3U
460
615
  #EXT-X-TARGETDURATION:5
461
616
  #EXTINF:5.0,
@@ -478,9 +633,10 @@ d.vtt
478
633
  fetched.push(path);
479
634
  return Promise.resolve(new Response(`WEBVTT\n\n00:00:00.000 --> 00:00:01.000\n${path}`));
480
635
  });
481
- const t = makeTrack({ uri: 'https://x.test/sub.m3u8' });
482
- controller.setTextTracks([t]);
483
- controller.setActiveTextTrack(t.id);
636
+ const h = build({ options: { selection: { id: 'a' } } });
637
+ // Media at t=12s should cause seg3 (10s..15s) to be fetched first.
638
+ h.playback.currentTime = 12;
639
+ await h.setTracks([makeTrack({ uri: 'https://x.test/sub.m3u8' })]);
484
640
  await flushUntil(() => fetched.length > 0);
485
641
  expect(fetched[0]).toBe('c.vtt');
486
642
  });
@@ -489,99 +645,42 @@ d.vtt
489
645
  requester.request.and.callFake(() => new Promise((res) => {
490
646
  resolveResponse = res;
491
647
  }));
492
- const t = makeTrack();
493
- controller.setTextTracks([t]);
494
- controller.setActiveTextTrack(t.id);
495
- // The DOM TextTrack is created eagerly on activation so cues can be
496
- // rendered as they arrive during load.
497
- expect(media.lastTrack).not.toBeNull();
498
- controller.dispose();
648
+ const h = build({ options: { selection: { id: 'a' } } });
649
+ await h.setTracks([makeTrack()]);
650
+ const track = h.provider.lastTrack;
651
+ expect(track).not.toBeNull();
652
+ h.controller.dispose();
499
653
  // Resolving after dispose must not throw or push cues onto the track.
500
654
  resolveResponse(new Response(`WEBVTT\n\n00:00:00.500 --> 00:00:01.500\npost`));
501
655
  await flushPromises();
502
- expect(media.lastTrack?.mode).toBe('disabled');
503
- expect(media.lastTrack?.cues.length).toBe(0);
504
- expect(controller.activeTextTrack).toBeNull();
505
- expect(controller.textTracks).toEqual([]);
656
+ expect(track?.mode).toBe('disabled');
657
+ expect(track?.storedCues.length).toBe(0);
658
+ expect(h.controller.activeTextTrack).toBeNull();
659
+ expect(h.controller.textTracks).toEqual([]);
506
660
  });
507
- it('handles tracks with null language', async () => {
661
+ it('creates the DOM track with an undefined language for null-language tracks', async () => {
508
662
  respondVtt(`WEBVTT\n`);
509
- const t = makeTrack({ language: null });
510
- controller.setTextTracks([t]);
511
- controller.setActiveTextTrack(t.id);
663
+ const h = build({ options: { selection: { id: 'a' } } });
664
+ await h.setTracks([makeTrack({ language: null })]);
512
665
  await flushPromises();
513
- expect(media.addTextTrack).toHaveBeenCalledWith('subtitles', 'English', undefined);
666
+ expect(h.provider.getOrCreate).toHaveBeenCalledWith('subtitles', 'English', undefined);
514
667
  });
515
- it('setTextTracks with same array reference is a no-op', () => {
668
+ it('setting the same resolved track list twice emits textTracksChange once', async () => {
516
669
  const handler = jasmine.createSpy();
517
- controller.on('textTracksChange', handler);
670
+ const h = build();
671
+ h.controller.on('textTracksChange', handler);
518
672
  const list = [makeTrack()];
519
- controller.setTextTracks(list);
520
- controller.setTextTracks(list);
673
+ await h.setTracks(list);
674
+ await h.setTracks([makeTrack()]); // deep-equal contents
521
675
  expect(handler).toHaveBeenCalledTimes(1);
522
676
  });
523
- it('clearing active when none is active is a no-op', () => {
524
- const handler = jasmine.createSpy();
525
- controller.on('activeTextTrackChange', handler);
526
- controller.setActiveTextTrack(null);
527
- expect(handler).not.toHaveBeenCalled();
528
- });
529
- it('selecting same id is a no-op', () => {
677
+ it('re-selecting the same id is a no-op', async () => {
530
678
  respondVtt(`WEBVTT\n`);
531
- const t = makeTrack();
679
+ const h = build({ options: { selection: { id: 'a' } } });
680
+ await h.setTracks([makeTrack()]);
532
681
  const handler = jasmine.createSpy();
533
- controller.setTextTracks([t]);
534
- controller.setActiveTextTrack(t.id);
535
- controller.on('activeTextTrackChange', handler);
536
- controller.setActiveTextTrack(t.id);
682
+ h.controller.on('activeTextTrackChange', handler);
683
+ h.select({ id: 'a' });
537
684
  expect(handler).not.toHaveBeenCalled();
538
685
  });
539
- describe('preferredTextLanguage', () => {
540
- const en = makeTrack({ id: 'en', language: 'en', label: 'English' });
541
- const es = makeTrack({ id: 'es', language: 'es', label: 'Espanol' });
542
- let preferredTextLanguage;
543
- let prefController;
544
- beforeEach(() => {
545
- preferredTextLanguage = data(null);
546
- prefController = new SidecarTextTrackController({
547
- media: null,
548
- preferredTextLanguage,
549
- });
550
- });
551
- afterEach(() => prefController.dispose());
552
- it('selects the matching track when the preference changes', () => {
553
- prefController.setTextTracks([en, es]);
554
- expect(prefController.activeTextTrack).toBeNull();
555
- preferredTextLanguage.value = 'es';
556
- expect(prefController.activeTextTrack?.id).toBe('es');
557
- });
558
- it('clears the selection when the preference is set back to null', () => {
559
- prefController.setTextTracks([en, es]);
560
- preferredTextLanguage.value = 'es';
561
- expect(prefController.activeTextTrack?.id).toBe('es');
562
- preferredTextLanguage.value = null;
563
- expect(prefController.activeTextTrack).toBeNull();
564
- });
565
- it('re-applies the preference when the track list changes', () => {
566
- preferredTextLanguage.value = 'es';
567
- // No tracks yet, so nothing matches.
568
- expect(prefController.activeTextTrack).toBeNull();
569
- prefController.setTextTracks([en, es]);
570
- expect(prefController.activeTextTrack?.id).toBe('es');
571
- });
572
- it('a preferred language wins over forced auto-selection', () => {
573
- preferredTextLanguage.value = 'es';
574
- prefController.setTextTracks([
575
- makeTrack({ id: 'en-forced', language: 'en', forced: true }),
576
- es,
577
- ]);
578
- expect(prefController.activeTextTrack?.id).toBe('es');
579
- });
580
- it('leaves forced auto-selection intact while no preference is set', () => {
581
- prefController.setTextTracks([
582
- makeTrack({ id: 'forced', forced: true }),
583
- ]);
584
- expect(prefController.activeTextTrack?.id).toBe('forced');
585
- });
586
- });
587
686
  });