@editframe/elements 0.18.8-beta.0 → 0.18.19-beta.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 (55) hide show
  1. package/dist/elements/EFMedia/AssetIdMediaEngine.js +4 -1
  2. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +3 -4
  3. package/dist/elements/EFMedia/AssetMediaEngine.js +16 -15
  4. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +30 -11
  5. package/dist/elements/EFMedia/BaseMediaEngine.js +83 -31
  6. package/dist/elements/EFMedia/JitMediaEngine.d.ts +2 -4
  7. package/dist/elements/EFMedia/JitMediaEngine.js +12 -12
  8. package/dist/elements/EFVideo.d.ts +0 -1
  9. package/dist/elements/EFVideo.js +0 -9
  10. package/dist/elements/TargetController.js +3 -2
  11. package/package.json +2 -2
  12. package/src/elements/EFMedia/AssetIdMediaEngine.ts +10 -1
  13. package/src/elements/EFMedia/AssetMediaEngine.ts +25 -21
  14. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +311 -0
  15. package/src/elements/EFMedia/BaseMediaEngine.ts +168 -51
  16. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +2 -12
  17. package/src/elements/EFMedia/JitMediaEngine.ts +25 -16
  18. package/src/elements/EFTemporal.browsertest.ts +47 -0
  19. package/src/elements/EFVideo.browsertest.ts +127 -281
  20. package/src/elements/EFVideo.ts +9 -9
  21. package/src/elements/TargetController.ts +6 -2
  22. package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +3 -8
  23. package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +3 -8
  24. package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +3 -8
  25. package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +3 -8
  26. package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +3 -8
  27. package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +3 -8
  28. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
  29. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +4 -9
  30. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
  31. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +4 -9
  32. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
  33. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +4 -9
  34. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
  35. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +4 -9
  36. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
  37. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +4 -9
  38. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
  39. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +4 -9
  40. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +2 -4
  41. package/test/recordReplayProxyPlugin.js +46 -31
  42. package/test/setup.ts +16 -0
  43. package/test/useAssetMSW.ts +54 -0
  44. package/test/useMSW.ts +4 -11
  45. package/types.json +1 -1
  46. package/dist/elements/MediaController.d.ts +0 -30
  47. package/src/elements/EFMedia/BaseMediaEngine.test.ts +0 -164
  48. package/src/elements/MediaController.ts +0 -98
  49. package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/data.bin +0 -0
  50. package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/metadata.json +0 -22
  51. package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/data.bin +0 -0
  52. package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/metadata.json +0 -22
  53. package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/data.bin +0 -0
  54. package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/metadata.json +0 -22
  55. /package/dist/elements/EFMedia/{BaseMediaEngine.test.d.ts → BaseMediaEngine.browsertest.d.ts} +0 -0
@@ -0,0 +1,311 @@
1
+ import { describe, vi } from "vitest";
2
+ import { test as baseTest } from "../../../test/useMSW.js";
3
+
4
+ import type { EFMedia } from "../EFMedia.js";
5
+ import { BaseMediaEngine, mediaCache } from "./BaseMediaEngine.js";
6
+
7
+ const test = baseTest.extend<{}>({});
8
+
9
+ // Test implementation of BaseMediaEngine for testing
10
+ class TestMediaEngine extends BaseMediaEngine {
11
+ fetchMediaSegment = vi.fn();
12
+ public host: EFMedia;
13
+
14
+ constructor(host: EFMedia) {
15
+ super(host);
16
+ this.host = host;
17
+ }
18
+
19
+ get videoRendition() {
20
+ return {
21
+ trackId: 1,
22
+ src: "test-video.mp4",
23
+ segmentDurationMs: 2000,
24
+ };
25
+ }
26
+
27
+ get audioRendition() {
28
+ return {
29
+ trackId: 2,
30
+ src: "test-audio.mp4",
31
+ segmentDurationMs: 1000,
32
+ };
33
+ }
34
+ }
35
+
36
+ describe("BaseMediaEngine deduplication", () => {
37
+ test("should fetch segment successfully", async ({ expect }) => {
38
+ const host = document.createElement("ef-video") as EFMedia;
39
+ const engine = new TestMediaEngine(host);
40
+ engine.fetchMediaSegment.mockResolvedValue(new ArrayBuffer(1024));
41
+
42
+ const rendition = { trackId: 1, src: "test.mp4" };
43
+ const result = await engine.fetchMediaSegment(1, rendition);
44
+
45
+ expect(result).toEqual(new ArrayBuffer(1024));
46
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
47
+ expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
48
+ });
49
+
50
+ test("should deduplicate concurrent requests for same segment", async ({
51
+ expect,
52
+ }) => {
53
+ const host = document.createElement("ef-video") as EFMedia;
54
+ const engine = new TestMediaEngine(host);
55
+ const mockSegmentData = new ArrayBuffer(1024);
56
+ engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
57
+
58
+ const rendition = { trackId: 1, src: "test.mp4" };
59
+
60
+ // Make two concurrent requests for the same segment
61
+ const [result1, result2] = await Promise.all([
62
+ engine.fetchMediaSegmentWithDeduplication(1, rendition),
63
+ engine.fetchMediaSegmentWithDeduplication(1, rendition),
64
+ ]);
65
+
66
+ // Both should return the same result
67
+ expect(result1).toBe(mockSegmentData);
68
+ expect(result2).toBe(mockSegmentData);
69
+
70
+ // But fetchMediaSegment should only be called once due to deduplication
71
+ expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
72
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
73
+ });
74
+
75
+ test("should handle different segments as separate requests", async ({
76
+ expect,
77
+ }) => {
78
+ const host = document.createElement("ef-video") as EFMedia;
79
+ const engine = new TestMediaEngine(host);
80
+ const mockSegmentData = new ArrayBuffer(1024);
81
+ engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
82
+
83
+ const rendition = { trackId: 1, src: "test.mp4" };
84
+
85
+ // Make concurrent requests for different segments
86
+ const [result1, result2] = await Promise.all([
87
+ engine.fetchMediaSegmentWithDeduplication(1, rendition),
88
+ engine.fetchMediaSegmentWithDeduplication(2, rendition),
89
+ ]);
90
+
91
+ expect(result1).toBe(mockSegmentData);
92
+ expect(result2).toBe(mockSegmentData);
93
+
94
+ // Should call fetchMediaSegment twice for different segments
95
+ expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
96
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
97
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(2, rendition);
98
+ });
99
+
100
+ test("should handle different renditions as separate requests", async ({
101
+ expect,
102
+ }) => {
103
+ const host = document.createElement("ef-video") as EFMedia;
104
+ const engine = new TestMediaEngine(host);
105
+ const mockSegmentData = new ArrayBuffer(1024);
106
+ engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
107
+
108
+ const rendition1 = { trackId: 1, src: "test1.mp4" };
109
+ const rendition2 = { trackId: 2, src: "test2.mp4" };
110
+
111
+ // Make concurrent requests for same segment but different renditions
112
+ const [result1, result2] = await Promise.all([
113
+ engine.fetchMediaSegmentWithDeduplication(1, rendition1),
114
+ engine.fetchMediaSegmentWithDeduplication(1, rendition2),
115
+ ]);
116
+
117
+ expect(result1).toBe(mockSegmentData);
118
+ expect(result2).toBe(mockSegmentData);
119
+
120
+ // Should call fetchMediaSegment twice for different renditions
121
+ expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
122
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition1);
123
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition2);
124
+ });
125
+
126
+ test("should propagate errors correctly", async ({ expect }) => {
127
+ const host = document.createElement("ef-video") as EFMedia;
128
+ const engine = new TestMediaEngine(host);
129
+ const error = new Error("Fetch failed");
130
+ engine.fetchMediaSegment.mockRejectedValue(error);
131
+
132
+ const rendition = { trackId: 1, src: "test.mp4" };
133
+
134
+ await expect(
135
+ engine.fetchMediaSegmentWithDeduplication(1, rendition),
136
+ ).rejects.toThrow("Fetch failed");
137
+ expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
138
+ });
139
+
140
+ test("should retry failed requests after error", async ({ expect }) => {
141
+ const host = document.createElement("ef-video") as EFMedia;
142
+ const engine = new TestMediaEngine(host);
143
+ const rendition = { trackId: 1, src: "test.mp4" };
144
+ const error = new Error("First attempt failed");
145
+ const mockSegmentData = new ArrayBuffer(1024);
146
+
147
+ // First request fails
148
+ engine.fetchMediaSegment.mockRejectedValueOnce(error);
149
+ await expect(
150
+ engine.fetchMediaSegmentWithDeduplication(1, rendition),
151
+ ).rejects.toThrow("First attempt failed");
152
+
153
+ // Second request should succeed (not deduplicated since first failed)
154
+ engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
155
+ const result = await engine.fetchMediaSegmentWithDeduplication(
156
+ 1,
157
+ rendition,
158
+ );
159
+
160
+ expect(result).toBe(mockSegmentData);
161
+ expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
162
+ });
163
+
164
+ test("should track pending requests correctly", async ({ expect }) => {
165
+ const host = document.createElement("ef-video") as EFMedia;
166
+ const engine = new TestMediaEngine(host);
167
+ const mockSegmentData = new ArrayBuffer(1024);
168
+ engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
169
+
170
+ const rendition = { trackId: 1, src: "test.mp4" };
171
+
172
+ // Start a request but don't await it
173
+ const promise = engine.fetchMediaSegmentWithDeduplication(1, rendition);
174
+
175
+ // Should detect that segment is being fetched
176
+ expect(engine.isSegmentBeingFetched(1, rendition)).toBe(true);
177
+ expect(engine.getActiveSegmentRequestCount()).toBe(1);
178
+
179
+ // Different segment should not be detected as being fetched
180
+ expect(engine.isSegmentBeingFetched(2, rendition)).toBe(false);
181
+
182
+ await promise.then(() => {
183
+ // After completion, should no longer be detected as being fetched
184
+ expect(engine.isSegmentBeingFetched(1, rendition)).toBe(false);
185
+ expect(engine.getActiveSegmentRequestCount()).toBe(0);
186
+ });
187
+ });
188
+
189
+ test("should cancel all requests correctly", async ({ expect }) => {
190
+ const host = document.createElement("ef-video") as EFMedia;
191
+ const engine = new TestMediaEngine(host);
192
+ const rendition = { trackId: 1, src: "test.mp4" };
193
+
194
+ // Start a request
195
+ engine.fetchMediaSegmentWithDeduplication(1, rendition);
196
+ expect(engine.getActiveSegmentRequestCount()).toBe(1);
197
+
198
+ // Cancel all requests
199
+ engine.cancelAllSegmentRequests();
200
+ expect(engine.getActiveSegmentRequestCount()).toBe(0);
201
+ });
202
+ });
203
+
204
+ describe("BaseMediaEngine caching", () => {
205
+ test("should use shared media cache", async ({ expect }) => {
206
+ // Test that the cache is shared across instances
207
+ expect(mediaCache).toBe(mediaCache);
208
+ });
209
+
210
+ test("should have cache size limit", async ({ expect }) => {
211
+ // Test that the cache has the expected size limit
212
+ expect(mediaCache.maxSize).toBe(100 * 1024 * 1024);
213
+ });
214
+ });
215
+
216
+ describe("BaseMediaEngine abort signal handling", () => {
217
+ test("should handle abort signals independently in BaseMediaEngine", async ({
218
+ expect,
219
+ }) => {
220
+ // Create a test host that actually has a fetch method
221
+ const host = {
222
+ fetch: vi
223
+ .fn()
224
+ .mockImplementation(
225
+ () =>
226
+ new Promise((resolve) =>
227
+ setTimeout(
228
+ () => resolve({ arrayBuffer: () => new ArrayBuffer(1024) }),
229
+ 100,
230
+ ),
231
+ ),
232
+ ),
233
+ } as any;
234
+
235
+ const engine = new TestMediaEngine(host);
236
+
237
+ // Create two abort controllers
238
+ const controller1 = new AbortController();
239
+ const controller2 = new AbortController();
240
+
241
+ // Start two requests with different abort signals to the same URL
242
+ const promise1 = engine.fetchMedia("test-url", controller1.signal);
243
+ const promise2 = engine.fetchMedia("test-url", controller2.signal);
244
+
245
+ // Abort only the first request
246
+ controller1.abort();
247
+
248
+ // First request should fail with AbortError
249
+ await expect(promise1).rejects.toThrow("Aborted");
250
+
251
+ // Second request should still succeed
252
+ const result2 = await promise2;
253
+ expect(result2).toBeInstanceOf(ArrayBuffer);
254
+ expect(result2.byteLength).toBe(1024);
255
+
256
+ // The network request should only be made once due to deduplication
257
+ // This validates our fix: deduplication works but signals are independent
258
+ expect(host.fetch).toHaveBeenCalledTimes(1);
259
+ });
260
+
261
+ test("should handle immediate abort in BaseMediaEngine", async ({
262
+ expect,
263
+ }) => {
264
+ const host = {
265
+ fetch: vi
266
+ .fn()
267
+ .mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
268
+ } as any;
269
+
270
+ const engine = new TestMediaEngine(host);
271
+
272
+ // Create an already aborted signal
273
+ const controller = new AbortController();
274
+ controller.abort();
275
+
276
+ // Request should fail immediately with AbortError
277
+ await expect(
278
+ engine.fetchMedia("test-url", controller.signal),
279
+ ).rejects.toThrow("Aborted");
280
+
281
+ // No network request should be made since abort happens in wrapper method
282
+ expect(host.fetch).not.toHaveBeenCalled();
283
+ });
284
+
285
+ test("should maintain deduplication while respecting individual abort signals", async ({
286
+ expect,
287
+ }) => {
288
+ const host = {
289
+ fetch: vi
290
+ .fn()
291
+ .mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
292
+ } as any;
293
+
294
+ const engine = new TestMediaEngine(host);
295
+
296
+ // Clear any existing cache to ensure clean test
297
+ mediaCache.clear();
298
+
299
+ // Make two requests without signals
300
+ const promise1 = engine.fetchMedia("test-url");
301
+ const promise2 = engine.fetchMedia("test-url");
302
+
303
+ // Both should succeed
304
+ const [result1, result2] = await Promise.all([promise1, promise2]);
305
+ expect(result1).toBeInstanceOf(ArrayBuffer);
306
+ expect(result2).toBeInstanceOf(ArrayBuffer);
307
+
308
+ // Should deduplicate to one network request
309
+ expect(host.fetch).toHaveBeenCalledTimes(1);
310
+ });
311
+ });
@@ -7,16 +7,19 @@ import type {
7
7
  import { SizeAwareLRUCache } from "../../utils/LRUCache.js";
8
8
  import type { EFMedia } from "../EFMedia.js";
9
9
 
10
- // 100MB cache limit
11
- const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024);
10
+ // Global instances shared across all media engines
11
+ export const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024); // 100MB cache limit
12
+ export const globalRequestDeduplicator = new RequestDeduplicator();
12
13
 
13
14
  export abstract class BaseMediaEngine {
14
- // Request deduplicator for in-flight segment fetches
15
- private requestDeduplicator = new RequestDeduplicator();
15
+ protected host: EFMedia;
16
+
17
+ constructor(host: EFMedia) {
18
+ this.host = host;
19
+ }
16
20
 
17
21
  abstract get videoRendition(): VideoRendition | undefined;
18
22
  abstract get audioRendition(): AudioRendition | undefined;
19
- abstract get host(): EFMedia;
20
23
 
21
24
  getVideoRendition(): VideoRendition {
22
25
  if (!this.videoRendition) {
@@ -42,27 +45,179 @@ export abstract class BaseMediaEngine {
42
45
  return `${rendition.src}-${rendition.id}-${segmentId}-${rendition.trackId}`;
43
46
  }
44
47
 
48
+ /**
49
+ * Unified fetch method with caching and global deduplication
50
+ * All requests (media, manifest, init segments) go through this method
51
+ */
52
+ protected async fetchWithCache(
53
+ url: string,
54
+ options: {
55
+ responseType: "arrayBuffer" | "json";
56
+ headers?: Record<string, string>;
57
+ signal?: AbortSignal;
58
+ },
59
+ ): Promise<any> {
60
+ const { responseType, headers, signal } = options;
61
+
62
+ // Create cache key that includes URL and headers for proper isolation
63
+ // Note: We don't include signal in cache key as it would prevent proper deduplication
64
+ const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
65
+
66
+ // Check cache first
67
+ const cached = mediaCache.get(cacheKey);
68
+ if (cached) {
69
+ // If we have a cached promise, we need to handle the caller's abort signal
70
+ // without affecting the underlying request that other instances might be using
71
+ if (signal) {
72
+ return this.handleAbortForCachedRequest(cached, signal);
73
+ }
74
+ return cached;
75
+ }
76
+
77
+ // Use global deduplicator to prevent concurrent requests for the same resource
78
+ // Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
79
+ const promise = globalRequestDeduplicator.executeRequest(
80
+ cacheKey,
81
+ async () => {
82
+ try {
83
+ // Make the fetch request WITHOUT the signal - let each caller handle their own abort
84
+ // This prevents one instance's abort from affecting other instances using the shared cache
85
+ const response = await this.host.fetch(url, { headers });
86
+
87
+ if (responseType === "json") {
88
+ return response.json();
89
+ }
90
+ return response.arrayBuffer();
91
+ } catch (error) {
92
+ // If the request was aborted, don't cache the error
93
+ if (error instanceof DOMException && error.name === "AbortError") {
94
+ // Remove from cache so other requests can retry
95
+ mediaCache.delete(cacheKey);
96
+ }
97
+ throw error;
98
+ }
99
+ },
100
+ );
101
+
102
+ // Cache the promise (not the result) to handle concurrent requests
103
+ mediaCache.set(cacheKey, promise);
104
+
105
+ // Handle the case where the promise might be aborted
106
+ promise.catch((error) => {
107
+ // If the request was aborted, remove it from cache to prevent corrupted data
108
+ if (error instanceof DOMException && error.name === "AbortError") {
109
+ mediaCache.delete(cacheKey);
110
+ }
111
+ });
112
+
113
+ // If the caller has a signal, handle abort logic without affecting the underlying request
114
+ if (signal) {
115
+ return this.handleAbortForCachedRequest(promise, signal);
116
+ }
117
+
118
+ return promise;
119
+ }
120
+
121
+ /**
122
+ * Handles abort logic for a cached request without affecting the underlying fetch
123
+ * This allows multiple instances to share the same cached request while each
124
+ * manages their own abort behavior
125
+ */
126
+ private handleAbortForCachedRequest<T>(
127
+ promise: Promise<T>,
128
+ signal: AbortSignal,
129
+ ): Promise<T> {
130
+ // If signal is already aborted, reject immediately
131
+ if (signal.aborted) {
132
+ throw new DOMException("Aborted", "AbortError");
133
+ }
134
+
135
+ // Return a promise that respects the caller's abort signal
136
+ // but doesn't affect the underlying cached request
137
+ return Promise.race([
138
+ promise,
139
+ new Promise<never>((_, reject) => {
140
+ signal.addEventListener("abort", () => {
141
+ reject(new DOMException("Aborted", "AbortError"));
142
+ });
143
+ }),
144
+ ]);
145
+ }
146
+
147
+ // Public wrapper methods that delegate to fetchWithCache
148
+ async fetchMedia(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
149
+ // Check abort signal immediately before any processing
150
+ if (signal?.aborted) {
151
+ throw new DOMException("Aborted", "AbortError");
152
+ }
153
+ return this.fetchWithCache(url, { responseType: "arrayBuffer", signal });
154
+ }
155
+
156
+ async fetchManifest(url: string, signal?: AbortSignal): Promise<any> {
157
+ // Check abort signal immediately before any processing
158
+ if (signal?.aborted) {
159
+ throw new DOMException("Aborted", "AbortError");
160
+ }
161
+ return this.fetchWithCache(url, { responseType: "json", signal });
162
+ }
163
+
164
+ async fetchMediaWithHeaders(
165
+ url: string,
166
+ headers: Record<string, string>,
167
+ signal?: AbortSignal,
168
+ ): Promise<ArrayBuffer> {
169
+ // Check abort signal immediately before any processing
170
+ if (signal?.aborted) {
171
+ throw new DOMException("Aborted", "AbortError");
172
+ }
173
+ return this.fetchWithCache(url, {
174
+ responseType: "arrayBuffer",
175
+ headers,
176
+ signal,
177
+ });
178
+ }
179
+
180
+ // Legacy methods for backward compatibility
181
+ async fetchMediaCache(
182
+ url: string,
183
+ signal?: AbortSignal,
184
+ ): Promise<ArrayBuffer> {
185
+ return this.fetchMedia(url, signal);
186
+ }
187
+
188
+ async fetchManifestCache(url: string, signal?: AbortSignal): Promise<any> {
189
+ return this.fetchManifest(url, signal);
190
+ }
191
+
192
+ async fetchMediaCacheWithHeaders(
193
+ url: string,
194
+ headers: Record<string, string>,
195
+ signal?: AbortSignal,
196
+ ): Promise<ArrayBuffer> {
197
+ return this.fetchMediaWithHeaders(url, headers, signal);
198
+ }
199
+
45
200
  /**
46
201
  * Abstract method for actual segment fetching - implemented by subclasses
47
202
  */
48
- abstract fetchMediaSegmentImpl(
203
+ abstract fetchMediaSegment(
49
204
  segmentId: number,
50
205
  rendition: { trackId: number | undefined; src: string },
51
206
  ): Promise<ArrayBuffer>;
52
207
 
53
208
  /**
54
209
  * Fetch media segment with built-in deduplication
55
- * Eliminates the need for separate coordinators - cleaner architecture
210
+ * Now uses global deduplication for all requests
56
211
  */
57
- async fetchMediaSegment(
212
+ async fetchMediaSegmentWithDeduplication(
58
213
  segmentId: number,
59
214
  rendition: { trackId: number | undefined; src: string },
60
215
  _signal?: AbortSignal,
61
216
  ): Promise<ArrayBuffer> {
62
217
  const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
63
218
 
64
- return this.requestDeduplicator.executeRequest(cacheKey, async () => {
65
- return this.fetchMediaSegmentImpl(segmentId, rendition);
219
+ return globalRequestDeduplicator.executeRequest(cacheKey, async () => {
220
+ return this.fetchMediaSegment(segmentId, rendition);
66
221
  });
67
222
  }
68
223
 
@@ -74,59 +229,21 @@ export abstract class BaseMediaEngine {
74
229
  rendition: { src: string; trackId: number | undefined },
75
230
  ): boolean {
76
231
  const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
77
- return this.requestDeduplicator.isPending(cacheKey);
232
+ return globalRequestDeduplicator.isPending(cacheKey);
78
233
  }
79
234
 
80
235
  /**
81
236
  * Get count of active segment requests (for debugging/monitoring)
82
237
  */
83
238
  getActiveSegmentRequestCount(): number {
84
- return this.requestDeduplicator.getPendingCount();
239
+ return globalRequestDeduplicator.getPendingCount();
85
240
  }
86
241
 
87
242
  /**
88
243
  * Cancel all active segment requests (for cleanup)
89
244
  */
90
245
  cancelAllSegmentRequests(): void {
91
- this.requestDeduplicator.clear();
92
- }
93
-
94
- async fetchMediaCache(mediaUrl: string) {
95
- const cached = mediaCache.get(mediaUrl);
96
- if (cached) {
97
- return cached;
98
- }
99
- const promise = this.host
100
- .fetch(mediaUrl)
101
- .then((response) => response.arrayBuffer());
102
- mediaCache.set(mediaUrl, promise);
103
- return promise;
104
- }
105
-
106
- /**
107
- * Enhanced caching method that supports custom headers (e.g., Range requests)
108
- * Cache key includes both URL and headers for proper cache isolation
109
- */
110
- async fetchMediaCacheWithHeaders(
111
- mediaUrl: string,
112
- headers?: Record<string, string>,
113
- signal?: AbortSignal,
114
- ) {
115
- // Create a cache key that includes both URL and headers
116
- const cacheKey = headers
117
- ? `${mediaUrl}:${JSON.stringify(headers)}`
118
- : mediaUrl;
119
-
120
- const cached = mediaCache.get(cacheKey);
121
- if (cached) {
122
- return cached;
123
- }
124
-
125
- const promise = this.host
126
- .fetch(mediaUrl, { headers, signal })
127
- .then((response) => response.arrayBuffer());
128
- mediaCache.set(cacheKey, promise);
129
- return promise;
246
+ globalRequestDeduplicator.clear();
130
247
  }
131
248
 
132
249
  /**
@@ -109,15 +109,10 @@ describe("JitMediaEngine", () => {
109
109
 
110
110
  test("returns undefined audio rendition when none available", ({
111
111
  urlGenerator,
112
- emptyManifestResponse,
113
112
  host,
114
113
  expect,
115
114
  }) => {
116
- const engine = new JitMediaEngine(
117
- host,
118
- urlGenerator,
119
- emptyManifestResponse,
120
- );
115
+ const engine = new JitMediaEngine(host, urlGenerator);
121
116
 
122
117
  expect(engine.audioRendition).toBeUndefined();
123
118
  });
@@ -138,15 +133,10 @@ describe("JitMediaEngine", () => {
138
133
 
139
134
  test("returns undefined video rendition when none available", ({
140
135
  urlGenerator,
141
- emptyManifestResponse,
142
136
  host,
143
137
  expect,
144
138
  }) => {
145
- const engine = new JitMediaEngine(
146
- host,
147
- urlGenerator,
148
- emptyManifestResponse,
149
- );
139
+ const engine = new JitMediaEngine(host, urlGenerator);
150
140
 
151
141
  expect(engine.videoRendition).toBeUndefined();
152
142
  });
@@ -10,18 +10,19 @@ import type { EFMedia } from "../EFMedia.js";
10
10
  import { BaseMediaEngine } from "./BaseMediaEngine";
11
11
 
12
12
  export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
13
+ private urlGenerator: UrlGenerator;
14
+ private data: ManifestResponse = {} as ManifestResponse;
15
+
13
16
  static async fetch(host: EFMedia, urlGenerator: UrlGenerator, url: string) {
14
- const response = await host.fetch(url);
15
- const data = (await response.json()) as ManifestResponse;
16
- return new JitMediaEngine(host, urlGenerator, data);
17
+ const engine = new JitMediaEngine(host, urlGenerator);
18
+ const data = await engine.fetchManifest(url);
19
+ engine.data = data;
20
+ return engine;
17
21
  }
18
22
 
19
- constructor(
20
- public host: EFMedia,
21
- private urlGenerator: UrlGenerator,
22
- private data: ManifestResponse,
23
- ) {
24
- super();
23
+ constructor(host: EFMedia, urlGenerator: UrlGenerator) {
24
+ super(host);
25
+ this.urlGenerator = urlGenerator;
25
26
  }
26
27
 
27
28
  get durationMs() {
@@ -33,9 +34,13 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
33
34
  }
34
35
 
35
36
  get audioRendition(): AudioRendition | undefined {
36
- const rendition = this.data.audioRenditions[0];
37
+ if (!this.data.audioRenditions || this.data.audioRenditions.length === 0) {
38
+ return undefined;
39
+ }
37
40
 
41
+ const rendition = this.data.audioRenditions[0];
38
42
  if (!rendition) return undefined;
43
+
39
44
  return {
40
45
  id: rendition.id as RenditionId,
41
46
  trackId: undefined,
@@ -46,9 +51,13 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
46
51
  }
47
52
 
48
53
  get videoRendition(): VideoRendition | undefined {
49
- const rendition = this.data.videoRenditions[0];
54
+ if (!this.data.videoRenditions || this.data.videoRenditions.length === 0) {
55
+ return undefined;
56
+ }
50
57
 
58
+ const rendition = this.data.videoRenditions[0];
51
59
  if (!rendition) return undefined;
60
+
52
61
  return {
53
62
  id: rendition.id as RenditionId,
54
63
  trackId: undefined,
@@ -74,12 +83,12 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
74
83
  rendition.id,
75
84
  this,
76
85
  );
77
- const response = await this.host.fetch(url, { signal });
78
- const arrayBuffer = await response.arrayBuffer();
79
- return arrayBuffer;
86
+
87
+ // Use unified fetch method
88
+ return this.fetchMedia(url, signal);
80
89
  }
81
90
 
82
- async fetchMediaSegmentImpl(
91
+ async fetchMediaSegment(
83
92
  segmentId: number,
84
93
  rendition: { id?: RenditionId; trackId: number | undefined; src: string },
85
94
  ) {
@@ -91,7 +100,7 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
91
100
  rendition.id,
92
101
  this,
93
102
  );
94
- return this.fetchMediaCache(url);
103
+ return this.fetchMedia(url);
95
104
  }
96
105
 
97
106
  computeSegmentId(