@editframe/elements 0.19.4-beta.0 → 0.20.1-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 (132) hide show
  1. package/dist/elements/ContextProxiesController.d.ts +40 -0
  2. package/dist/elements/ContextProxiesController.js +69 -0
  3. package/dist/elements/EFCaptions.d.ts +45 -6
  4. package/dist/elements/EFCaptions.js +220 -26
  5. package/dist/elements/EFImage.js +4 -1
  6. package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
  7. package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
  8. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
  9. package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
  10. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
  11. package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
  12. package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
  13. package/dist/elements/EFMedia/JitMediaEngine.js +15 -0
  14. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +2 -1
  15. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +2 -0
  16. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.d.ts +1 -1
  17. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.js +3 -1
  18. package/dist/elements/EFMedia/audioTasks/makeAudioInputTask.js +1 -1
  19. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.d.ts +1 -1
  20. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.js +6 -5
  21. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.js +3 -1
  22. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +2 -0
  23. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +2 -2
  24. package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
  25. package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
  26. package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
  27. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
  28. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +1 -1
  29. package/dist/elements/EFMedia.d.ts +2 -2
  30. package/dist/elements/EFMedia.js +25 -1
  31. package/dist/elements/EFSurface.browsertest.d.ts +0 -0
  32. package/dist/elements/EFSurface.d.ts +30 -0
  33. package/dist/elements/EFSurface.js +96 -0
  34. package/dist/elements/EFTemporal.js +7 -6
  35. package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
  36. package/dist/elements/EFThumbnailStrip.d.ts +86 -0
  37. package/dist/elements/EFThumbnailStrip.js +490 -0
  38. package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
  39. package/dist/elements/EFTimegroup.d.ts +6 -1
  40. package/dist/elements/EFTimegroup.js +53 -11
  41. package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
  42. package/dist/elements/updateAnimations.d.ts +5 -0
  43. package/dist/elements/updateAnimations.js +37 -13
  44. package/dist/getRenderInfo.js +1 -1
  45. package/dist/gui/ContextMixin.js +27 -14
  46. package/dist/gui/EFControls.browsertest.d.ts +0 -0
  47. package/dist/gui/EFControls.d.ts +38 -0
  48. package/dist/gui/EFControls.js +51 -0
  49. package/dist/gui/EFFilmstrip.d.ts +40 -1
  50. package/dist/gui/EFFilmstrip.js +240 -3
  51. package/dist/gui/EFPreview.js +2 -1
  52. package/dist/gui/EFScrubber.d.ts +6 -5
  53. package/dist/gui/EFScrubber.js +31 -21
  54. package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
  55. package/dist/gui/EFTimeDisplay.d.ts +2 -6
  56. package/dist/gui/EFTimeDisplay.js +13 -23
  57. package/dist/gui/TWMixin.js +1 -1
  58. package/dist/gui/currentTimeContext.d.ts +3 -0
  59. package/dist/gui/currentTimeContext.js +3 -0
  60. package/dist/gui/durationContext.d.ts +3 -0
  61. package/dist/gui/durationContext.js +3 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.js +4 -1
  64. package/dist/style.css +1 -1
  65. package/dist/transcoding/types/index.d.ts +11 -0
  66. package/dist/utils/LRUCache.d.ts +46 -0
  67. package/dist/utils/LRUCache.js +382 -1
  68. package/dist/utils/LRUCache.test.d.ts +1 -0
  69. package/package.json +2 -2
  70. package/src/elements/ContextProxiesController.ts +124 -0
  71. package/src/elements/EFCaptions.browsertest.ts +1820 -0
  72. package/src/elements/EFCaptions.ts +373 -36
  73. package/src/elements/EFImage.ts +4 -1
  74. package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
  75. package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
  76. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
  77. package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
  78. package/src/elements/EFMedia/JitMediaEngine.ts +34 -0
  79. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +6 -5
  80. package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +5 -0
  81. package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.ts +8 -5
  82. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +5 -5
  83. package/src/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.ts +11 -12
  84. package/src/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.ts +7 -4
  85. package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +5 -0
  86. package/src/elements/EFMedia/shared/AudioSpanUtils.ts +2 -2
  87. package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
  88. package/src/elements/EFMedia/shared/RenditionHelpers.browsertest.ts +2 -2
  89. package/src/elements/EFMedia/shared/RenditionHelpers.ts +2 -2
  90. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
  91. package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +1 -1
  92. package/src/elements/EFMedia.ts +38 -1
  93. package/src/elements/EFSurface.browsertest.ts +155 -0
  94. package/src/elements/EFSurface.ts +141 -0
  95. package/src/elements/EFTemporal.ts +14 -8
  96. package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
  97. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
  98. package/src/elements/EFThumbnailStrip.ts +905 -0
  99. package/src/elements/EFTimegroup.browsertest.ts +56 -7
  100. package/src/elements/EFTimegroup.ts +88 -16
  101. package/src/elements/updateAnimations.browsertest.ts +333 -11
  102. package/src/elements/updateAnimations.ts +68 -19
  103. package/src/gui/ContextMixin.browsertest.ts +0 -25
  104. package/src/gui/ContextMixin.ts +44 -20
  105. package/src/gui/EFControls.browsertest.ts +175 -0
  106. package/src/gui/EFControls.ts +84 -0
  107. package/src/gui/EFFilmstrip.ts +323 -4
  108. package/src/gui/EFPreview.ts +2 -1
  109. package/src/gui/EFScrubber.ts +29 -25
  110. package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
  111. package/src/gui/EFTimeDisplay.ts +12 -40
  112. package/src/gui/currentTimeContext.ts +5 -0
  113. package/src/gui/durationContext.ts +3 -0
  114. package/src/transcoding/types/index.ts +13 -0
  115. package/src/utils/LRUCache.test.ts +272 -0
  116. package/src/utils/LRUCache.ts +543 -0
  117. 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
  118. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +1 -1
  119. 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
  120. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +1 -1
  121. 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
  122. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +1 -1
  123. 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
  124. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +1 -1
  125. 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
  126. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +1 -1
  127. 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
  128. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/data.bin +1 -1
  129. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +1 -1
  130. package/types.json +1 -1
  131. package/dist/transcoding/cache/CacheManager.d.ts +0 -73
  132. package/src/transcoding/cache/CacheManager.ts +0 -208
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  deepGetTemporalElements,
3
- isEFTemporal,
4
3
  type TemporalMixinInterface,
5
4
  } from "./EFTemporal.ts";
6
5
 
@@ -8,7 +7,7 @@ import {
8
7
  export type AnimatableElement = TemporalMixinInterface & HTMLElement;
9
8
 
10
9
  // Constants
11
- const ANIMATION_PRECISION_OFFSET = 0.001;
10
+ const ANIMATION_PRECISION_OFFSET = 0.1; // Use 0.1ms to safely avoid completion threshold
12
11
  const DEFAULT_ANIMATION_ITERATIONS = 1;
13
12
  const PROGRESS_PROPERTY = "--ef-progress";
14
13
  const DURATION_PROPERTY = "--ef-duration";
@@ -39,8 +38,40 @@ export const evaluateTemporalState = (
39
38
  ? 1
40
39
  : Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
41
40
 
41
+ // Root timegroups should remain visible at exact end time, but other elements use exclusive end for clean transitions
42
+ const isRootTimegroup =
43
+ element.tagName.toLowerCase() === TIMEGROUP_TAGNAME &&
44
+ !(element as any).parentTimegroup;
45
+ const useInclusiveEnd = isRootTimegroup;
46
+
42
47
  const isVisible =
43
- element.startTimeMs <= timelineTimeMs && element.endTimeMs > timelineTimeMs;
48
+ element.startTimeMs <= timelineTimeMs &&
49
+ (useInclusiveEnd
50
+ ? element.endTimeMs >= timelineTimeMs
51
+ : element.endTimeMs > timelineTimeMs);
52
+
53
+ return { progress, isVisible, timelineTimeMs };
54
+ };
55
+
56
+ /**
57
+ * Evaluates element visibility specifically for animation coordination
58
+ * Uses inclusive end boundaries to prevent animation jumps at exact boundaries
59
+ */
60
+ export const evaluateTemporalStateForAnimation = (
61
+ element: AnimatableElement,
62
+ ): TemporalState => {
63
+ // Get timeline time from root timegroup, or use element's own time if it IS a timegroup
64
+ const timelineTimeMs = (element.rootTimegroup ?? element).currentTimeMs;
65
+
66
+ const progress =
67
+ element.durationMs <= 0
68
+ ? 1
69
+ : Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
70
+
71
+ // For animation coordination, use inclusive end for ALL elements to prevent visual jumps
72
+ const isVisible =
73
+ element.startTimeMs <= timelineTimeMs &&
74
+ element.endTimeMs >= timelineTimeMs;
44
75
 
45
76
  return { progress, isVisible, timelineTimeMs };
46
77
  };
@@ -80,9 +111,11 @@ const updateVisualState = (
80
111
  };
81
112
 
82
113
  /**
83
- * Coordinates animations to match timeline
114
+ * Coordinates animations for a single element and its subtree, using the element as the time source
84
115
  */
85
- const coordinateAnimations = (element: AnimatableElement): void => {
116
+ const coordinateAnimationsForSingleElement = (
117
+ element: AnimatableElement,
118
+ ): void => {
86
119
  const animations = element.getAnimations({ subtree: true });
87
120
 
88
121
  for (const animation of animations) {
@@ -96,17 +129,12 @@ const coordinateAnimations = (element: AnimatableElement): void => {
96
129
  }
97
130
 
98
131
  const target = effect.target;
99
- if (!target || target.closest(TIMEGROUP_TAGNAME) !== element) {
100
- continue;
101
- }
102
-
103
- const timeTarget = isEFTemporal(target)
104
- ? target
105
- : target.closest(TIMEGROUP_TAGNAME);
106
- if (!timeTarget) {
132
+ if (!target) {
107
133
  continue;
108
134
  }
109
135
 
136
+ // For animations in this element's subtree, always use this element as the time source
137
+ // This handles both animations directly on the temporal element and on its non-temporal children
110
138
  const timing = effect.getTiming();
111
139
  const duration = Number(timing.duration) || 0;
112
140
  const delay = Number(timing.delay) || 0;
@@ -118,8 +146,8 @@ const coordinateAnimations = (element: AnimatableElement): void => {
118
146
  continue;
119
147
  }
120
148
 
121
- // All timegroups are temporal, so always use ownCurrentTimeMs for local time coordination
122
- const currentTime = timeTarget.ownCurrentTimeMs ?? 0;
149
+ // Use the element itself as the time source (it's guaranteed to be temporal)
150
+ const currentTime = element.ownCurrentTimeMs ?? 0;
123
151
 
124
152
  if (currentTime < delay) {
125
153
  animation.currentTime = 0;
@@ -130,12 +158,22 @@ const coordinateAnimations = (element: AnimatableElement): void => {
130
158
  const currentIteration = Math.floor(adjustedTime / duration);
131
159
  const currentIterationTime = adjustedTime % duration;
132
160
 
161
+ // Calculate the total animation timeline length (delay + duration * iterations)
162
+ const totalAnimationLength = delay + duration * iterations;
163
+
164
+ // CRITICAL: Always keep currentTime below totalAnimationLength to prevent completion
165
+ const maxSafeCurrentTime =
166
+ totalAnimationLength - ANIMATION_PRECISION_OFFSET;
167
+
133
168
  if (currentIteration >= iterations) {
134
- animation.currentTime = duration - ANIMATION_PRECISION_OFFSET;
169
+ // Animation would be complete - clamp to just before completion
170
+ animation.currentTime = maxSafeCurrentTime;
135
171
  } else {
136
- animation.currentTime =
172
+ // Animation in progress - clamp to safe value within current iteration
173
+ const proposedCurrentTime =
137
174
  Math.min(currentIterationTime, duration - ANIMATION_PRECISION_OFFSET) +
138
175
  delay;
176
+ animation.currentTime = Math.min(proposedCurrentTime, maxSafeCurrentTime);
139
177
  }
140
178
  }
141
179
  };
@@ -151,7 +189,18 @@ export const updateAnimations = (element: AnimatableElement): void => {
151
189
  });
152
190
  updateVisualState(element, temporalState);
153
191
 
154
- if (temporalState.isVisible) {
155
- coordinateAnimations(element);
192
+ // Coordinate animations - use animation-specific visibility to prevent jumps at exact boundaries
193
+ const animationState = evaluateTemporalStateForAnimation(element);
194
+ if (animationState.isVisible) {
195
+ coordinateAnimationsForSingleElement(element);
156
196
  }
197
+
198
+ // Coordinate animations for child elements using animation-specific visibility
199
+ deepGetTemporalElements(element).forEach((temporalElement) => {
200
+ const childAnimationState =
201
+ evaluateTemporalStateForAnimation(temporalElement);
202
+ if (childAnimationState.isVisible) {
203
+ coordinateAnimationsForSingleElement(temporalElement);
204
+ }
205
+ });
157
206
  };
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
4
 
5
5
  import { ContextMixin } from "./ContextMixin.js";
6
6
 
7
- // Required to test timeupdate event, we need a duration, and timegroups are a quick way to do that
8
7
  import "../elements/EFTimegroup.js";
9
8
 
10
9
  @customElement("test-context")
@@ -529,30 +528,6 @@ describe("ContextMixin", () => {
529
528
  });
530
529
  });
531
530
 
532
- test("Time update event when the currentTimeMs changed", async () => {
533
- const timegroup = document.createElement("ef-timegroup");
534
- timegroup.mode = "fixed";
535
- timegroup.duration = "10s";
536
-
537
- const preview = document.createElement("test-context");
538
- preview.append(timegroup);
539
- document.body.append(preview);
540
-
541
- type CurrentTimeEvent = CustomEvent<{ currentTimeMs: number }>;
542
-
543
- // Expect the timeupdate event to be dispatched
544
- const timeupdatePromise = new Promise<CurrentTimeEvent>((resolve) => {
545
- preview.addEventListener(
546
- "timeupdate",
547
- (event: Event) => resolve(event as CurrentTimeEvent),
548
- { once: true },
549
- );
550
- });
551
- preview.currentTimeMs = 1000;
552
- const event = await timeupdatePromise;
553
- expect(event.detail.currentTimeMs).toBe(1000);
554
- });
555
-
556
531
  describe("Reactivity", () => {
557
532
  test("should update durationMs when child tree changes", async () => {
558
533
  const element = document.createElement("test-context-reactivity");
@@ -4,6 +4,8 @@ import { property, state } from "lit/decorators.js";
4
4
  import { EF_RENDERING } from "../EF_RENDERING.ts";
5
5
  import type { EFTimegroup } from "../elements/EFTimegroup.js";
6
6
  import { globalURLTokenDeduplicator } from "../transcoding/cache/URLTokenDeduplicator.js";
7
+ import { currentTimeContext } from "./currentTimeContext.js";
8
+ import { durationContext } from "./durationContext.js";
7
9
  import {
8
10
  type EFConfiguration,
9
11
  efConfigurationContext,
@@ -74,15 +76,12 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
74
76
  targetTimegroup: EFTimegroup | null = null;
75
77
 
76
78
  // Add reactive properties that depend on the targetTimegroup
77
- @state()
78
- get durationMs(): number {
79
- return this.targetTimegroup?.durationMs ?? 0;
80
- }
79
+ @provide({ context: durationContext })
80
+ @property({ type: Number })
81
+ durationMs = 0;
81
82
 
82
- @state()
83
- get endTimeMs(): number {
84
- return this.targetTimegroup?.endTimeMs ?? 0;
85
- }
83
+ @property({ type: Number })
84
+ endTimeMs = 0;
86
85
 
87
86
  @provide({ context: fetchContext })
88
87
  fetch = async (url: string, init: RequestInit = {}) => {
@@ -248,7 +247,8 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
248
247
  @property({ type: Boolean })
249
248
  rendering = false;
250
249
 
251
- @state()
250
+ @provide({ context: currentTimeContext })
251
+ @property({ type: Number })
252
252
  currentTimeMs = Number.NaN;
253
253
 
254
254
  #FPS = 30;
@@ -273,11 +273,24 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
273
273
  }
274
274
  } else if (mutation.type === "attributes") {
275
275
  // Watch for attribute changes that might affect duration
276
+ const durationAffectingAttributes = [
277
+ "duration",
278
+ "mode",
279
+ "trimstart",
280
+ "trimend",
281
+ "sourcein",
282
+ "sourceout",
283
+ ];
284
+
276
285
  if (
277
- mutation.attributeName === "duration" ||
278
- mutation.attributeName === "mode" ||
286
+ durationAffectingAttributes.includes(
287
+ mutation.attributeName || "",
288
+ ) ||
279
289
  (mutation.target instanceof Element &&
280
290
  (mutation.target.tagName === "EF-TIMEGROUP" ||
291
+ mutation.target.tagName === "EF-VIDEO" ||
292
+ mutation.target.tagName === "EF-AUDIO" ||
293
+ mutation.target.tagName === "EF-CAPTIONS" ||
281
294
  mutation.target.closest("ef-timegroup")))
282
295
  ) {
283
296
  shouldUpdate = true;
@@ -289,6 +302,8 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
289
302
  // Trigger an update to ensure reactive properties recalculate
290
303
  // Use a microtask to ensure DOM updates are complete
291
304
  queueMicrotask(() => {
305
+ // Recalculate duration and endTime when timegroup changes
306
+ this.updateDurationProperties();
292
307
  this.requestUpdate();
293
308
  // Also ensure the targetTimegroup updates its computed properties
294
309
  if (this.targetTimegroup) {
@@ -298,11 +313,29 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
298
313
  }
299
314
  });
300
315
 
316
+ /**
317
+ * Update duration properties when timegroup changes
318
+ */
319
+ updateDurationProperties(): void {
320
+ const newDuration = this.targetTimegroup?.durationMs ?? 0;
321
+ const newEndTime = this.targetTimegroup?.endTimeMs ?? 0;
322
+
323
+ if (this.durationMs !== newDuration) {
324
+ this.durationMs = newDuration;
325
+ }
326
+
327
+ if (this.endTimeMs !== newEndTime) {
328
+ this.endTimeMs = newEndTime;
329
+ }
330
+ }
331
+
301
332
  connectedCallback(): void {
302
333
  super.connectedCallback();
303
334
 
304
335
  // Initialize targetTimegroup
305
336
  this.targetTimegroup = this.querySelector("ef-timegroup");
337
+ // Initialize duration properties
338
+ this.updateDurationProperties();
306
339
 
307
340
  this.#timegroupObserver.observe(this, {
308
341
  childList: true,
@@ -342,15 +375,6 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
342
375
  return;
343
376
  }
344
377
  this.targetTimegroup.currentTimeMs = this.currentTimeMs;
345
- this.dispatchEvent(
346
- new CustomEvent("timeupdate", {
347
- detail: {
348
- currentTimeMs: this.currentTimeMs,
349
- progress:
350
- this.currentTimeMs / this.targetTimegroup.durationMs,
351
- },
352
- }),
353
- );
354
378
  }
355
379
  }
356
380
  }
@@ -0,0 +1,175 @@
1
+ import { html, LitElement } from "lit";
2
+ import { customElement } from "lit/decorators.js";
3
+ import { beforeEach, describe, expect, test } from "vitest";
4
+
5
+ import "../elements/EFTimegroup.js";
6
+ import { EFTargetable } from "../elements/TargetController.js";
7
+ import { ContextMixin } from "./ContextMixin.js";
8
+ import "./EFControls.js";
9
+ import { EFControls } from "./EFControls.js";
10
+ import "./EFPreview.js";
11
+
12
+ @customElement("test-context")
13
+ class TestContext extends EFTargetable(ContextMixin(LitElement)) {
14
+ render() {
15
+ return html`<slot></slot>`;
16
+ }
17
+ }
18
+
19
+ beforeEach(() => {
20
+ // Clean up localStorage
21
+ for (let i = 0; i < localStorage.length; i++) {
22
+ const key = localStorage.key(i);
23
+ if (typeof key !== "string") continue;
24
+ localStorage.removeItem(key);
25
+ }
26
+
27
+ // Clean up DOM
28
+ while (document.body.children.length) {
29
+ document.body.children[0]?.remove();
30
+ }
31
+ });
32
+
33
+ describe("EFControls", () => {
34
+ test("should be defined", () => {
35
+ expect(EFControls).toBeDefined();
36
+ });
37
+
38
+ test("can find and connect to target preview by ID", async () => {
39
+ // Create a preview with an ID
40
+ const preview = document.createElement("ef-preview") as any;
41
+ preview.id = "test-preview";
42
+
43
+ // Add a timegroup to the preview to give it duration
44
+ const timegroup = document.createElement("ef-timegroup");
45
+ timegroup.mode = "fixed";
46
+ timegroup.duration = "10s";
47
+ preview.appendChild(timegroup);
48
+
49
+ document.body.appendChild(preview);
50
+
51
+ // Create controls targeting the preview
52
+ const controls = document.createElement("ef-controls");
53
+ controls.target = "test-preview";
54
+ document.body.appendChild(controls);
55
+
56
+ // Wait for both elements to complete their updates
57
+ await preview.updateComplete;
58
+ await controls.updateComplete;
59
+
60
+ // The controls should have found and connected to the preview
61
+ expect(controls.targetElement).toBe(preview);
62
+ });
63
+
64
+ test("handles missing target gracefully", async () => {
65
+ const controls = document.createElement("ef-controls");
66
+ controls.target = "nonexistent-preview";
67
+ document.body.appendChild(controls);
68
+
69
+ // Wait for the controller to attempt connection
70
+ await controls.updateComplete;
71
+
72
+ // Should have no target but not crash
73
+ expect(controls.targetElement).toBe(null);
74
+ // Note: EFControls with context proxying doesn't have playing property
75
+ // It only proxies context requests to the target element
76
+ });
77
+
78
+ test("updates when target is set after connection", async () => {
79
+ const controls = document.createElement("ef-controls") as EFControls;
80
+ document.body.appendChild(controls);
81
+
82
+ // Initially no target
83
+ expect(controls.targetElement).toBe(null);
84
+
85
+ // Create preview
86
+ const preview = document.createElement("test-context") as TestContext;
87
+ preview.id = "test-preview";
88
+
89
+ const timegroup = document.createElement("ef-timegroup");
90
+ timegroup.mode = "fixed";
91
+ timegroup.duration = "10s";
92
+ preview.appendChild(timegroup);
93
+
94
+ document.body.appendChild(preview);
95
+
96
+ // Set target after both are connected
97
+ controls.target = "test-preview";
98
+
99
+ // Wait for both elements to complete their updates
100
+ await preview.updateComplete;
101
+ await controls.updateComplete;
102
+
103
+ // The controls should have found and connected to the target
104
+ expect(controls.targetElement).toBe(preview);
105
+ });
106
+
107
+ test("disconnects from target when removed", async () => {
108
+ const preview = document.createElement("test-context") as TestContext;
109
+ preview.id = "test-preview";
110
+
111
+ const timegroup = document.createElement("ef-timegroup");
112
+ timegroup.mode = "fixed";
113
+ timegroup.duration = "10s";
114
+ preview.appendChild(timegroup);
115
+
116
+ document.body.appendChild(preview);
117
+
118
+ const controls = document.createElement("ef-controls") as EFControls;
119
+ controls.target = "test-preview";
120
+ document.body.appendChild(controls);
121
+
122
+ // Wait for both elements to complete their updates
123
+ await preview.updateComplete;
124
+ await controls.updateComplete;
125
+
126
+ // Should be connected
127
+ expect(controls.targetElement).toBe(preview);
128
+
129
+ // Disconnect the controls
130
+ document.body.removeChild(controls);
131
+
132
+ // After disconnection, targetElement persists but should have no effect
133
+ // (TargetController only clears targetElement when target is removed, not when consumer disconnects)
134
+ expect(controls.targetElement).toBe(preview);
135
+ });
136
+
137
+ test.skip("works with child control elements - EFTogglePlay", async () => {
138
+ // Import the control element
139
+ await import("./EFTogglePlay.js");
140
+
141
+ const preview = document.createElement("test-context") as TestContext;
142
+ preview.id = "test-preview";
143
+ preview.playing = false;
144
+
145
+ const timegroup = document.createElement("ef-timegroup");
146
+ timegroup.mode = "fixed";
147
+ timegroup.duration = "10s";
148
+ preview.appendChild(timegroup);
149
+
150
+ document.body.appendChild(preview);
151
+
152
+ const controls = document.createElement("ef-controls") as EFControls;
153
+ controls.target = "test-preview";
154
+
155
+ const togglePlay = document.createElement("ef-toggle-play");
156
+ controls.appendChild(togglePlay);
157
+
158
+ document.body.appendChild(controls);
159
+
160
+ // Wait for all elements to complete their updates
161
+ await preview.updateComplete;
162
+ await controls.updateComplete;
163
+ await togglePlay.updateComplete;
164
+
165
+ // The toggle play should be connected to the controls' context (which syncs with preview)
166
+ expect((togglePlay as any).playing).toBe(false);
167
+
168
+ // Test that clicking the toggle affects the preview
169
+ preview.playing = true;
170
+ await preview.updateComplete;
171
+ await togglePlay.updateComplete;
172
+
173
+ expect((togglePlay as any).playing).toBe(true);
174
+ });
175
+ });
@@ -0,0 +1,84 @@
1
+ import { css, html, LitElement } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+
4
+ import { ContextProxyController } from "../elements/ContextProxiesController.js";
5
+ import { TargetController } from "../elements/TargetController.js";
6
+ import type { ContextMixinInterface } from "./ContextMixin.js";
7
+ import { targetTimegroupContext } from "./ContextMixin.js";
8
+ import { currentTimeContext } from "./currentTimeContext.js";
9
+ import { durationContext } from "./durationContext.js";
10
+ import { efConfigurationContext } from "./EFConfiguration.js";
11
+ import { efContext } from "./efContext.js";
12
+ import { fetchContext } from "./fetchContext.js";
13
+ import { focusContext } from "./focusContext.js";
14
+ import { focusedElementContext } from "./focusedElementContext.js";
15
+ import { loopContext, playingContext } from "./playingContext.js";
16
+
17
+ /**
18
+ * EFControls provides a way to control an ef-preview element that is not a direct ancestor.
19
+ * It bridges the contexts from a target preview element to its children controls.
20
+ *
21
+ * Usage:
22
+ * ```html
23
+ * <ef-preview id="my-preview">...</ef-preview>
24
+ *
25
+ * <ef-controls target="my-preview">
26
+ * <ef-toggle-play>
27
+ * <button slot="play">Play</button>
28
+ * <button slot="pause">Pause</button>
29
+ * </ef-toggle-play>
30
+ * <ef-scrubber></ef-scrubber>
31
+ * <ef-time-display></ef-time-display>
32
+ * </ef-controls>
33
+ * ```
34
+ */
35
+ @customElement("ef-controls")
36
+ export class EFControls extends LitElement {
37
+ static styles = css`
38
+ :host {
39
+ display: block;
40
+ }
41
+ `;
42
+
43
+ /**
44
+ * The ID of the ef-preview element to control
45
+ */
46
+ @property({ type: String })
47
+ target = "";
48
+
49
+ /**
50
+ * The target element (set by TargetController)
51
+ */
52
+ @state()
53
+ targetElement: ContextMixinInterface | null = null;
54
+
55
+ // @ts-expect-error controller is intentionally not referenced directly
56
+ #targetController = new TargetController(this);
57
+
58
+ // @ts-expect-error controller is intentionally not referenced directly
59
+ #contextProxyController = new ContextProxyController(this, {
60
+ target: () => this.targetElement,
61
+ contexts: [
62
+ playingContext,
63
+ loopContext,
64
+ currentTimeContext,
65
+ durationContext,
66
+ targetTimegroupContext,
67
+ focusedElementContext,
68
+ efContext,
69
+ fetchContext,
70
+ focusContext,
71
+ efConfigurationContext,
72
+ ],
73
+ });
74
+
75
+ render() {
76
+ return html`<slot></slot>`;
77
+ }
78
+ }
79
+
80
+ declare global {
81
+ interface HTMLElementTagNameMap {
82
+ "ef-controls": EFControls;
83
+ }
84
+ }