@editframe/elements 0.21.0-beta.0 → 0.23.7-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 (142) hide show
  1. package/dist/EF_FRAMEGEN.js +2 -3
  2. package/dist/attachContextRoot.d.ts +1 -0
  3. package/dist/attachContextRoot.js +9 -0
  4. package/dist/elements/ContextProxiesController.d.ts +1 -2
  5. package/dist/elements/EFAudio.js +2 -2
  6. package/dist/elements/EFCaptions.d.ts +1 -3
  7. package/dist/elements/EFCaptions.js +59 -51
  8. package/dist/elements/EFImage.js +2 -2
  9. package/dist/elements/EFMedia/AssetIdMediaEngine.js +1 -2
  10. package/dist/elements/EFMedia/AssetMediaEngine.js +1 -3
  11. package/dist/elements/EFMedia/BufferedSeekingInput.d.ts +1 -1
  12. package/dist/elements/EFMedia/BufferedSeekingInput.js +2 -4
  13. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +4 -7
  14. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +1 -2
  15. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +5 -9
  16. package/dist/elements/EFMedia/shared/BufferUtils.js +1 -3
  17. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +4 -7
  18. package/dist/elements/EFMedia.d.ts +19 -0
  19. package/dist/elements/EFMedia.js +19 -2
  20. package/dist/elements/EFSourceMixin.js +1 -1
  21. package/dist/elements/EFSurface.js +1 -1
  22. package/dist/elements/EFTemporal.browsertest.d.ts +11 -0
  23. package/dist/elements/EFTemporal.d.ts +10 -0
  24. package/dist/elements/EFTemporal.js +82 -5
  25. package/dist/elements/EFThumbnailStrip.js +9 -16
  26. package/dist/elements/EFTimegroup.browsertest.d.ts +3 -3
  27. package/dist/elements/EFTimegroup.d.ts +35 -14
  28. package/dist/elements/EFTimegroup.js +73 -120
  29. package/dist/elements/EFVideo.d.ts +10 -0
  30. package/dist/elements/EFVideo.js +15 -2
  31. package/dist/elements/EFWaveform.js +10 -18
  32. package/dist/elements/SampleBuffer.js +1 -2
  33. package/dist/elements/TargetController.js +2 -2
  34. package/dist/elements/renderTemporalAudio.d.ts +10 -0
  35. package/dist/elements/renderTemporalAudio.js +35 -0
  36. package/dist/elements/updateAnimations.js +7 -10
  37. package/dist/gui/ContextMixin.d.ts +5 -5
  38. package/dist/gui/ContextMixin.js +151 -117
  39. package/dist/gui/Controllable.browsertest.d.ts +0 -0
  40. package/dist/gui/Controllable.d.ts +15 -0
  41. package/dist/gui/Controllable.js +9 -0
  42. package/dist/gui/EFConfiguration.js +1 -1
  43. package/dist/gui/EFControls.browsertest.d.ts +11 -0
  44. package/dist/gui/EFControls.d.ts +18 -4
  45. package/dist/gui/EFControls.js +67 -25
  46. package/dist/gui/EFDial.browsertest.d.ts +0 -0
  47. package/dist/gui/EFDial.d.ts +18 -0
  48. package/dist/gui/EFDial.js +141 -0
  49. package/dist/gui/EFFilmstrip.browsertest.d.ts +11 -0
  50. package/dist/gui/EFFilmstrip.d.ts +12 -2
  51. package/dist/gui/EFFilmstrip.js +140 -34
  52. package/dist/gui/EFFitScale.js +2 -4
  53. package/dist/gui/EFFocusOverlay.js +1 -1
  54. package/dist/gui/EFPause.browsertest.d.ts +0 -0
  55. package/dist/gui/EFPause.d.ts +23 -0
  56. package/dist/gui/EFPause.js +59 -0
  57. package/dist/gui/EFPlay.browsertest.d.ts +0 -0
  58. package/dist/gui/EFPlay.d.ts +23 -0
  59. package/dist/gui/EFPlay.js +59 -0
  60. package/dist/gui/EFPreview.d.ts +4 -0
  61. package/dist/gui/EFPreview.js +15 -6
  62. package/dist/gui/EFResizableBox.browsertest.d.ts +0 -0
  63. package/dist/gui/EFResizableBox.d.ts +34 -0
  64. package/dist/gui/EFResizableBox.js +547 -0
  65. package/dist/gui/EFScrubber.d.ts +9 -3
  66. package/dist/gui/EFScrubber.js +7 -7
  67. package/dist/gui/EFTimeDisplay.d.ts +7 -1
  68. package/dist/gui/EFTimeDisplay.js +5 -5
  69. package/dist/gui/EFToggleLoop.d.ts +9 -3
  70. package/dist/gui/EFToggleLoop.js +6 -4
  71. package/dist/gui/EFTogglePlay.d.ts +12 -4
  72. package/dist/gui/EFTogglePlay.js +24 -19
  73. package/dist/gui/EFWorkbench.js +1 -1
  74. package/dist/gui/PlaybackController.d.ts +67 -0
  75. package/dist/gui/PlaybackController.js +310 -0
  76. package/dist/gui/TWMixin.js +1 -1
  77. package/dist/gui/TargetOrContextMixin.d.ts +10 -0
  78. package/dist/gui/TargetOrContextMixin.js +98 -0
  79. package/dist/gui/efContext.d.ts +2 -2
  80. package/dist/index.d.ts +4 -0
  81. package/dist/index.js +5 -1
  82. package/dist/otel/setupBrowserTracing.d.ts +1 -1
  83. package/dist/otel/setupBrowserTracing.js +6 -4
  84. package/dist/otel/tracingHelpers.js +1 -2
  85. package/dist/style.css +1 -1
  86. package/package.json +5 -5
  87. package/src/elements/ContextProxiesController.ts +10 -10
  88. package/src/elements/EFAudio.ts +1 -0
  89. package/src/elements/EFCaptions.browsertest.ts +128 -58
  90. package/src/elements/EFCaptions.ts +60 -34
  91. package/src/elements/EFImage.browsertest.ts +1 -2
  92. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +3 -0
  93. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +1 -1
  94. package/src/elements/EFMedia.browsertest.ts +8 -15
  95. package/src/elements/EFMedia.ts +38 -7
  96. package/src/elements/EFSurface.browsertest.ts +2 -6
  97. package/src/elements/EFSurface.ts +1 -0
  98. package/src/elements/EFTemporal.browsertest.ts +58 -1
  99. package/src/elements/EFTemporal.ts +140 -4
  100. package/src/elements/EFThumbnailStrip.browsertest.ts +2 -8
  101. package/src/elements/EFThumbnailStrip.ts +1 -0
  102. package/src/elements/EFTimegroup.browsertest.ts +6 -7
  103. package/src/elements/EFTimegroup.ts +163 -244
  104. package/src/elements/EFVideo.browsertest.ts +143 -47
  105. package/src/elements/EFVideo.ts +26 -0
  106. package/src/elements/FetchContext.browsertest.ts +7 -2
  107. package/src/elements/TargetController.browsertest.ts +1 -0
  108. package/src/elements/TargetController.ts +1 -0
  109. package/src/elements/renderTemporalAudio.ts +108 -0
  110. package/src/elements/updateAnimations.browsertest.ts +181 -6
  111. package/src/elements/updateAnimations.ts +6 -6
  112. package/src/gui/ContextMixin.browsertest.ts +274 -27
  113. package/src/gui/ContextMixin.ts +230 -175
  114. package/src/gui/Controllable.browsertest.ts +258 -0
  115. package/src/gui/Controllable.ts +41 -0
  116. package/src/gui/EFControls.browsertest.ts +294 -80
  117. package/src/gui/EFControls.ts +139 -28
  118. package/src/gui/EFDial.browsertest.ts +84 -0
  119. package/src/gui/EFDial.ts +172 -0
  120. package/src/gui/EFFilmstrip.browsertest.ts +712 -0
  121. package/src/gui/EFFilmstrip.ts +213 -23
  122. package/src/gui/EFPause.browsertest.ts +202 -0
  123. package/src/gui/EFPause.ts +73 -0
  124. package/src/gui/EFPlay.browsertest.ts +202 -0
  125. package/src/gui/EFPlay.ts +73 -0
  126. package/src/gui/EFPreview.ts +20 -5
  127. package/src/gui/EFResizableBox.browsertest.ts +79 -0
  128. package/src/gui/EFResizableBox.ts +898 -0
  129. package/src/gui/EFScrubber.ts +7 -5
  130. package/src/gui/EFTimeDisplay.browsertest.ts +19 -19
  131. package/src/gui/EFTimeDisplay.ts +3 -1
  132. package/src/gui/EFToggleLoop.ts +6 -5
  133. package/src/gui/EFTogglePlay.ts +30 -23
  134. package/src/gui/PlaybackController.ts +522 -0
  135. package/src/gui/TWMixin.css +3 -0
  136. package/src/gui/TargetOrContextMixin.ts +185 -0
  137. package/src/gui/efContext.ts +2 -2
  138. package/src/otel/setupBrowserTracing.ts +17 -12
  139. package/test/cache-integration-verification.browsertest.ts +1 -1
  140. package/types.json +1 -1
  141. package/dist/elements/ContextProxiesController.js +0 -49
  142. /package/dist/_virtual/{_@oxc-project_runtime@0.93.0 → _@oxc-project_runtime@0.94.0}/helpers/decorate.js +0 -0
@@ -0,0 +1,522 @@
1
+ import { ContextProvider } from "@lit/context";
2
+ import { Task, TaskStatus } from "@lit/task";
3
+ import type { ReactiveController, ReactiveControllerHost } from "lit";
4
+ import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
5
+ import { currentTimeContext } from "./currentTimeContext.js";
6
+ import { durationContext } from "./durationContext.js";
7
+ import { loopContext, playingContext } from "./playingContext.js";
8
+
9
+ interface PlaybackHost extends HTMLElement, ReactiveControllerHost {
10
+ currentTimeMs: number;
11
+ durationMs: number;
12
+ endTimeMs: number;
13
+ frameTask: { run(): void; taskComplete: Promise<unknown> };
14
+ renderAudio?(fromMs: number, toMs: number): Promise<AudioBuffer>;
15
+ waitForMediaDurations?(): Promise<void>;
16
+ saveTimeToLocalStorage?(time: number): void;
17
+ loadTimeFromLocalStorage?(): number | undefined;
18
+ requestUpdate(property?: string): void;
19
+ updateComplete: Promise<boolean>;
20
+ playing: boolean;
21
+ loop: boolean;
22
+ play(): void;
23
+ pause(): void;
24
+ playbackController?: PlaybackController;
25
+ parentTimegroup?: any;
26
+ rootTimegroup?: any;
27
+ }
28
+
29
+ export type PlaybackControllerUpdateEvent = {
30
+ property: "playing" | "loop" | "currentTimeMs";
31
+ value: boolean | number;
32
+ };
33
+
34
+ /**
35
+ * Manages playback state and audio-driven timing for root temporal elements
36
+ *
37
+ * Created automatically when a temporal element becomes a root (no parent timegroup)
38
+ * Provides playback contexts (playing, loop, currentTimeMs, durationMs) to descendants
39
+ * Handles:
40
+ * - Audio-driven playback with Web Audio API
41
+ * - Seek and frame rendering throttling
42
+ * - Time state management with pending seek handling
43
+ * - Playback loop behavior
44
+ *
45
+ * Works with any temporal element (timegroups or standalone media) via PlaybackHost interface
46
+ */
47
+ export class PlaybackController implements ReactiveController {
48
+ #host: PlaybackHost;
49
+ #playing = false;
50
+ #loop = false;
51
+ #listeners = new Set<(event: PlaybackControllerUpdateEvent) => void>();
52
+ #playingProvider: ContextProvider<typeof playingContext>;
53
+ #loopProvider: ContextProvider<typeof loopContext>;
54
+ #currentTimeMsProvider: ContextProvider<typeof currentTimeContext>;
55
+ #durationMsProvider: ContextProvider<typeof durationContext>;
56
+
57
+ #FPS = 30;
58
+ #MS_PER_FRAME = 1000 / this.#FPS;
59
+ #playbackAudioContext: AudioContext | null = null;
60
+ #playbackAnimationFrameRequest: number | null = null;
61
+ #AUDIO_PLAYBACK_SLICE_MS = ((47 * 1024) / 48000) * 1000;
62
+
63
+ #frameTaskInProgress = false;
64
+ #pendingFrameTaskRun = false;
65
+ #processingPendingFrameTask = false;
66
+
67
+ #currentTime: number | undefined = undefined;
68
+ #seekInProgress = false;
69
+ #pendingSeekTime: number | undefined;
70
+ #processingPendingSeek = false;
71
+ #loopingPlayback = false; // Track if we're in a looping playback session
72
+ #playbackWrapTimeSeconds = 0; // The AudioContext time when we wrapped
73
+
74
+ seekTask!: Task<readonly [number | undefined], number | undefined>;
75
+
76
+ constructor(host: PlaybackHost) {
77
+ this.#host = host;
78
+ host.addController(this);
79
+
80
+ this.seekTask = new Task(this.#host, {
81
+ autoRun: false,
82
+ args: () => [this.#pendingSeekTime ?? this.#currentTime] as const,
83
+ onComplete: () => {},
84
+ task: async ([targetTime]) => {
85
+ await this.#host.waitForMediaDurations?.();
86
+ const newTime = Math.max(
87
+ 0,
88
+ Math.min(targetTime ?? 0, this.#host.durationMs / 1000),
89
+ );
90
+ this.#currentTime = newTime;
91
+ this.#host.requestUpdate("currentTime");
92
+ this.#currentTimeMsProvider.setValue(this.currentTimeMs);
93
+ this.#notifyListeners({
94
+ property: "currentTimeMs",
95
+ value: this.currentTimeMs,
96
+ });
97
+ await this.runThrottledFrameTask();
98
+ this.#host.saveTimeToLocalStorage?.(newTime);
99
+ this.#seekInProgress = false;
100
+ return newTime;
101
+ },
102
+ });
103
+
104
+ this.#playingProvider = new ContextProvider(host, {
105
+ context: playingContext,
106
+ initialValue: this.#playing,
107
+ });
108
+ this.#loopProvider = new ContextProvider(host, {
109
+ context: loopContext,
110
+ initialValue: this.#loop,
111
+ });
112
+ this.#currentTimeMsProvider = new ContextProvider(host, {
113
+ context: currentTimeContext,
114
+ initialValue: host.currentTimeMs,
115
+ });
116
+ this.#durationMsProvider = new ContextProvider(host, {
117
+ context: durationContext,
118
+ initialValue: host.durationMs,
119
+ });
120
+ }
121
+
122
+ get currentTime(): number {
123
+ return this.#currentTime ?? 0;
124
+ }
125
+
126
+ set currentTime(time: number) {
127
+ time = Math.max(0, Math.min(this.#host.durationMs / 1000, time));
128
+ if (Number.isNaN(time)) {
129
+ return;
130
+ }
131
+ if (time === this.#currentTime && !this.#processingPendingSeek) {
132
+ return;
133
+ }
134
+ if (this.#pendingSeekTime === time) {
135
+ return;
136
+ }
137
+
138
+ if (this.#seekInProgress) {
139
+ this.#pendingSeekTime = time;
140
+ this.#currentTime = time;
141
+ return;
142
+ }
143
+
144
+ this.#currentTime = time;
145
+ this.#seekInProgress = true;
146
+
147
+ this.seekTask.run().finally(() => {
148
+ if (
149
+ this.#pendingSeekTime !== undefined &&
150
+ this.#pendingSeekTime !== time
151
+ ) {
152
+ const pendingTime = this.#pendingSeekTime;
153
+ this.#pendingSeekTime = undefined;
154
+ this.#processingPendingSeek = true;
155
+ try {
156
+ this.currentTime = pendingTime;
157
+ } finally {
158
+ this.#processingPendingSeek = false;
159
+ }
160
+ } else {
161
+ this.#pendingSeekTime = undefined;
162
+ }
163
+ });
164
+ }
165
+
166
+ get playing(): boolean {
167
+ return this.#playing;
168
+ }
169
+
170
+ setPlaying(value: boolean): void {
171
+ if (this.#playing === value) return;
172
+ this.#playing = value;
173
+ this.#playingProvider.setValue(value);
174
+ this.#host.requestUpdate("playing");
175
+ this.#notifyListeners({ property: "playing", value });
176
+
177
+ if (value) {
178
+ this.startPlayback();
179
+ } else {
180
+ this.stopPlayback();
181
+ }
182
+ }
183
+
184
+ get loop(): boolean {
185
+ return this.#loop;
186
+ }
187
+
188
+ setLoop(value: boolean): void {
189
+ if (this.#loop === value) return;
190
+ this.#loop = value;
191
+ this.#loopProvider.setValue(value);
192
+ this.#host.requestUpdate("loop");
193
+ this.#notifyListeners({ property: "loop", value });
194
+ }
195
+
196
+ get currentTimeMs(): number {
197
+ return this.currentTime * 1000;
198
+ }
199
+
200
+ setCurrentTimeMs(value: number): void {
201
+ this.currentTime = value / 1000;
202
+ }
203
+
204
+ // Update time during playback without triggering a seek
205
+ // Used by #syncPlayheadToAudioContext to avoid frame drops
206
+ #updatePlaybackTime(timeMs: number): void {
207
+ const timeSec = timeMs / 1000;
208
+ if (this.#currentTime === timeSec) {
209
+ return;
210
+ }
211
+ this.#currentTime = timeSec;
212
+ this.#host.requestUpdate("currentTime");
213
+ this.#currentTimeMsProvider.setValue(timeMs);
214
+ this.#notifyListeners({
215
+ property: "currentTimeMs",
216
+ value: timeMs,
217
+ });
218
+ // Trigger frame rendering without the async seek mechanism
219
+ this.runThrottledFrameTask();
220
+ }
221
+
222
+ play(): void {
223
+ this.setPlaying(true);
224
+ }
225
+
226
+ pause(): void {
227
+ this.setPlaying(false);
228
+ }
229
+
230
+ hostConnected(): void {
231
+ if (this.#playing) {
232
+ this.startPlayback();
233
+ } else {
234
+ this.#host.waitForMediaDurations?.().then(() => {
235
+ const maybeLoadedTime = this.#host.loadTimeFromLocalStorage?.();
236
+ if (maybeLoadedTime !== undefined) {
237
+ this.currentTime = maybeLoadedTime;
238
+ } else if (this.#currentTime === undefined) {
239
+ this.#currentTime = 0;
240
+ }
241
+ if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
242
+ this.seekTask.run();
243
+ }
244
+ });
245
+ }
246
+ }
247
+
248
+ hostDisconnected(): void {
249
+ this.pause();
250
+ }
251
+
252
+ hostUpdated(): void {
253
+ this.#durationMsProvider.setValue(this.#host.durationMs);
254
+ this.#currentTimeMsProvider.setValue(this.currentTimeMs);
255
+ }
256
+
257
+ async runThrottledFrameTask(): Promise<void> {
258
+ if (this.#frameTaskInProgress) {
259
+ this.#pendingFrameTaskRun = true;
260
+ while (this.#frameTaskInProgress) {
261
+ await this.#host.frameTask.taskComplete;
262
+ }
263
+ return;
264
+ }
265
+
266
+ this.#frameTaskInProgress = true;
267
+
268
+ try {
269
+ await this.#host.frameTask.run();
270
+ } catch (error) {
271
+ console.error("Frame task error:", error);
272
+ } finally {
273
+ this.#frameTaskInProgress = false;
274
+
275
+ if (this.#pendingFrameTaskRun && !this.#processingPendingFrameTask) {
276
+ this.#pendingFrameTaskRun = false;
277
+ this.#processingPendingFrameTask = true;
278
+ try {
279
+ await this.runThrottledFrameTask();
280
+ } finally {
281
+ this.#processingPendingFrameTask = false;
282
+ }
283
+ } else {
284
+ this.#pendingFrameTaskRun = false;
285
+ }
286
+ }
287
+ }
288
+
289
+ addListener(listener: (event: PlaybackControllerUpdateEvent) => void): void {
290
+ this.#listeners.add(listener);
291
+ }
292
+
293
+ removeListener(
294
+ listener: (event: PlaybackControllerUpdateEvent) => void,
295
+ ): void {
296
+ this.#listeners.delete(listener);
297
+ }
298
+
299
+ #notifyListeners(event: PlaybackControllerUpdateEvent): void {
300
+ for (const listener of this.#listeners) {
301
+ listener(event);
302
+ }
303
+ }
304
+
305
+ remove(): void {
306
+ this.stopPlayback();
307
+ this.#listeners.clear();
308
+ this.#host.removeController(this);
309
+ }
310
+
311
+ #syncPlayheadToAudioContext(startMs: number) {
312
+ const audioContextTime = this.#playbackAudioContext?.currentTime ?? 0;
313
+ const endMs = this.#host.endTimeMs;
314
+
315
+ // Calculate raw time based on audio context
316
+ let rawTimeMs: number;
317
+ if (
318
+ this.#playbackWrapTimeSeconds > 0 &&
319
+ audioContextTime >= this.#playbackWrapTimeSeconds
320
+ ) {
321
+ // After wrap: time since wrap, wrapped to duration
322
+ const timeSinceWrap =
323
+ (audioContextTime - this.#playbackWrapTimeSeconds) * 1000;
324
+ rawTimeMs = timeSinceWrap % endMs;
325
+ } else {
326
+ // Before wrap or no wrap: normal calculation
327
+ rawTimeMs = startMs + audioContextTime * 1000;
328
+
329
+ // If looping and we've reached the end, wrap around
330
+ if (this.#loopingPlayback && rawTimeMs >= endMs) {
331
+ rawTimeMs = rawTimeMs % endMs;
332
+ }
333
+ }
334
+
335
+ const nextTimeMs =
336
+ Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;
337
+
338
+ // During playback, update time directly without triggering seek
339
+ // This avoids frame drops at the loop boundary
340
+ this.#updatePlaybackTime(nextTimeMs);
341
+
342
+ // Only check for end if we haven't already handled looping
343
+ if (!this.#loopingPlayback && nextTimeMs >= endMs) {
344
+ this.maybeLoopPlayback();
345
+ return;
346
+ }
347
+
348
+ this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {
349
+ this.#syncPlayheadToAudioContext(startMs);
350
+ });
351
+ }
352
+
353
+ private async maybeLoopPlayback() {
354
+ if (this.#loop) {
355
+ // Loop enabled: reset to beginning and restart playback
356
+ // We restart the audio system directly without changing #playing state
357
+ // to keep the play button in sync
358
+ this.setCurrentTimeMs(0);
359
+ // Restart in next frame without awaiting to minimize gap
360
+ requestAnimationFrame(() => {
361
+ this.startPlayback();
362
+ });
363
+ } else {
364
+ // No loop: reset to beginning and stop
365
+ // This ensures play button works when clicked again
366
+ this.setCurrentTimeMs(0);
367
+ this.pause();
368
+ }
369
+ }
370
+
371
+ private async stopPlayback() {
372
+ if (this.#playbackAudioContext) {
373
+ if (this.#playbackAudioContext.state !== "closed") {
374
+ await this.#playbackAudioContext.close();
375
+ }
376
+ }
377
+ if (this.#playbackAnimationFrameRequest) {
378
+ cancelAnimationFrame(this.#playbackAnimationFrameRequest);
379
+ }
380
+ this.#playbackAudioContext = null;
381
+ this.#playbackAnimationFrameRequest = null;
382
+ }
383
+
384
+ private async startPlayback() {
385
+ await this.stopPlayback();
386
+ const host = this.#host;
387
+ if (!host) {
388
+ return;
389
+ }
390
+
391
+ if (host.waitForMediaDurations) {
392
+ await host.waitForMediaDurations();
393
+ }
394
+
395
+ const currentMs = this.currentTimeMs;
396
+ const fromMs = currentMs;
397
+ const toMs = host.endTimeMs;
398
+
399
+ if (fromMs >= toMs) {
400
+ this.pause();
401
+ return;
402
+ }
403
+
404
+ let bufferCount = 0;
405
+ this.#playbackAudioContext = new AudioContext({
406
+ latencyHint: "playback",
407
+ });
408
+ this.#loopingPlayback = this.#loop; // Remember if we're in a looping session
409
+ this.#playbackWrapTimeSeconds = 0; // Reset wrap time
410
+
411
+ if (this.#playbackAnimationFrameRequest) {
412
+ cancelAnimationFrame(this.#playbackAnimationFrameRequest);
413
+ }
414
+ this.#syncPlayheadToAudioContext(currentMs);
415
+ const playbackContext = this.#playbackAudioContext;
416
+ if (playbackContext.state === "suspended") {
417
+ console.warn(
418
+ "AudioContext is suspended, media playback will not work until user has interacted with page.",
419
+ );
420
+ this.setPlaying(false);
421
+ return;
422
+ }
423
+ await playbackContext.suspend();
424
+
425
+ // Track the logical media time (what position in the media we're rendering)
426
+ // vs the AudioContext schedule time (when to play it)
427
+ let logicalTimeMs = currentMs;
428
+ let audioContextTimeMs = 0; // Tracks the schedule position in the AudioContext timeline
429
+ let hasWrapped = false;
430
+
431
+ const fillBuffer = async () => {
432
+ if (bufferCount > 2) {
433
+ return;
434
+ }
435
+ const canFillBuffer = await queueBufferSource();
436
+ if (canFillBuffer) {
437
+ fillBuffer();
438
+ }
439
+ };
440
+
441
+ const queueBufferSource = async () => {
442
+ // Check if we've already wrapped and aren't looping anymore
443
+ if (hasWrapped && !this.#loopingPlayback) {
444
+ return false;
445
+ }
446
+
447
+ const startMs = logicalTimeMs;
448
+ const endMs = Math.min(
449
+ logicalTimeMs + this.#AUDIO_PLAYBACK_SLICE_MS,
450
+ toMs,
451
+ );
452
+
453
+ // Will this slice reach the end?
454
+ const willReachEnd = endMs >= toMs;
455
+
456
+ if (!host.renderAudio) {
457
+ return false;
458
+ }
459
+
460
+ const audioBuffer = await host.renderAudio(startMs, endMs);
461
+ bufferCount++;
462
+ const source = playbackContext.createBufferSource();
463
+ source.buffer = audioBuffer;
464
+ source.connect(playbackContext.destination);
465
+ // Schedule this buffer to play at the current audioContextTime position
466
+ source.start(audioContextTimeMs / 1000);
467
+
468
+ const sliceDurationMs = endMs - startMs;
469
+
470
+ source.onended = () => {
471
+ bufferCount--;
472
+
473
+ if (willReachEnd) {
474
+ if (!this.#loopingPlayback) {
475
+ // Not looping, end playback
476
+ this.maybeLoopPlayback();
477
+ } else {
478
+ // Looping: continue filling buffer after wrap
479
+ fillBuffer();
480
+ }
481
+ } else {
482
+ // Continue filling buffer
483
+ fillBuffer();
484
+ }
485
+ };
486
+
487
+ // Advance the AudioContext schedule time
488
+ audioContextTimeMs += sliceDurationMs;
489
+
490
+ // If this buffer reaches the end and we're looping, immediately queue the wraparound
491
+ if (willReachEnd && this.#loopingPlayback) {
492
+ // Mark that we've wrapped
493
+ hasWrapped = true;
494
+ // Store when we wrapped (relative to when playback started, which is time 0 in AudioContext)
495
+ // This is the duration from start to end
496
+ this.#playbackWrapTimeSeconds = (toMs - fromMs) / 1000;
497
+ // Reset logical time to beginning
498
+ logicalTimeMs = 0;
499
+ // Continue buffering will happen in fillBuffer() call below
500
+ } else {
501
+ // Normal advance
502
+ logicalTimeMs = endMs;
503
+ }
504
+
505
+ return true;
506
+ };
507
+
508
+ try {
509
+ await fillBuffer();
510
+ await playbackContext.resume();
511
+ } catch (error) {
512
+ // Ignore errors if AudioContext is closed or during test cleanup
513
+ if (
514
+ error instanceof Error &&
515
+ (error.name === "InvalidStateError" || error.message.includes("closed"))
516
+ ) {
517
+ return;
518
+ }
519
+ throw error;
520
+ }
521
+ }
522
+ }
@@ -1,3 +1,6 @@
1
+ /* biome-ignore lint/suspicious/noUnknownAtRules: @tailwind is a valid Tailwind CSS directive */
1
2
  @tailwind base;
3
+ /* biome-ignore lint/suspicious/noUnknownAtRules: @tailwind is a valid Tailwind CSS directive */
2
4
  @tailwind components;
5
+ /* biome-ignore lint/suspicious/noUnknownAtRules: @tailwind is a valid Tailwind CSS directive */
3
6
  @tailwind utilities;
@@ -0,0 +1,185 @@
1
+ import { type Context, consume } from "@lit/context";
2
+ import type { LitElement } from "lit";
3
+ import { property, state } from "lit/decorators.js";
4
+ import { isEFTemporal } from "../elements/EFTemporal.js";
5
+ import { TargetController } from "../elements/TargetController.js";
6
+ import { type ControllableInterface, isControllable } from "./Controllable.js";
7
+ import { currentTimeContext } from "./currentTimeContext.js";
8
+ import { durationContext } from "./durationContext.js";
9
+ import { loopContext, playingContext } from "./playingContext.js";
10
+
11
+ type Constructor<T = {}> = new (...args: any[]) => T;
12
+
13
+ class ContextRequestEvent extends Event {
14
+ context: Context<any, any>;
15
+ contextTarget: Element;
16
+ callback: (value: any, unsubscribe: () => void) => void;
17
+ subscribe: boolean;
18
+
19
+ constructor(
20
+ context: Context<any, any>,
21
+ contextTarget: Element,
22
+ callback: (value: any, unsubscribe: () => void) => void,
23
+ subscribe: boolean,
24
+ ) {
25
+ super("context-request", { bubbles: true, composed: true });
26
+ this.context = context;
27
+ this.contextTarget = contextTarget;
28
+ this.callback = callback;
29
+ this.subscribe = subscribe ?? false;
30
+ }
31
+ }
32
+
33
+ export function TargetOrContextMixin<T extends Constructor<LitElement>>(
34
+ superClass: T,
35
+ contextToProxy: Context<any, any>,
36
+ ) {
37
+ class TargetOrContextClass extends superClass {
38
+ @property({ type: String })
39
+ target = "";
40
+
41
+ @state()
42
+ targetElement: ControllableInterface | null = null;
43
+
44
+ // @ts-expect-error contextToProxy is generic but provides ControllableInterface at runtime
45
+ @consume({ context: contextToProxy, subscribe: true })
46
+ contextFromParent: ControllableInterface | null = null;
47
+
48
+ #targetController?: TargetController;
49
+ #contextUnsubscribe?: () => void;
50
+ #contextRequestHandler?: (event: Event) => void;
51
+ #additionalContextUnsubscribes = new Map<Context<any, any>, () => void>();
52
+
53
+ get effectiveContext(): ControllableInterface | null {
54
+ return this.targetElement ?? this.contextFromParent;
55
+ }
56
+
57
+ connectedCallback() {
58
+ super.connectedCallback();
59
+ if (this.target) {
60
+ this.#targetController = new TargetController(
61
+ this as any as LitElement & {
62
+ targetElement: Element | null;
63
+ target: string;
64
+ },
65
+ );
66
+ }
67
+
68
+ // Intercept context-request events and redirect them to targetElement
69
+ this.#contextRequestHandler = (event: Event) => {
70
+ if (this.targetElement && event.type === "context-request") {
71
+ event.stopPropagation();
72
+ this.targetElement.dispatchEvent(
73
+ new (event.constructor as any)(event.type, event),
74
+ );
75
+ }
76
+ };
77
+ this.addEventListener(
78
+ "context-request",
79
+ this.#contextRequestHandler,
80
+ true,
81
+ );
82
+ }
83
+
84
+ #subscribeToTargetContext() {
85
+ if (!this.targetElement) return;
86
+
87
+ this.#contextUnsubscribe?.();
88
+
89
+ // Unsubscribe from all additional contexts
90
+ for (const unsubscribe of this.#additionalContextUnsubscribes.values()) {
91
+ unsubscribe();
92
+ }
93
+ this.#additionalContextUnsubscribes.clear();
94
+
95
+ // Subscribe to efContext
96
+ const event = new ContextRequestEvent(
97
+ contextToProxy,
98
+ this,
99
+ (value, unsubscribe) => {
100
+ (this as any).contextFromParent = value;
101
+ this.#contextUnsubscribe = unsubscribe;
102
+ },
103
+ true,
104
+ );
105
+ this.targetElement.dispatchEvent(event);
106
+
107
+ // Subscribe to additional contexts that controls commonly need
108
+ const additionalContexts: Array<[Context<any, any>, string]> = [
109
+ [playingContext, "playing"],
110
+ [loopContext, "loop"],
111
+ [currentTimeContext, "currentTimeMs"],
112
+ [durationContext, "durationMs"],
113
+ ];
114
+
115
+ for (const [context, propertyName] of additionalContexts) {
116
+ const contextEvent = new ContextRequestEvent(
117
+ context,
118
+ this,
119
+ (value, unsubscribe) => {
120
+ // Update the control's property if it exists
121
+ if (propertyName in this) {
122
+ (this as any)[propertyName] = value;
123
+ }
124
+ this.#additionalContextUnsubscribes.set(context, unsubscribe);
125
+ },
126
+ true,
127
+ );
128
+ this.targetElement.dispatchEvent(contextEvent);
129
+ }
130
+ }
131
+
132
+ updated(changedProperties: Map<string | number | symbol, unknown>): void {
133
+ super.updated?.(changedProperties);
134
+
135
+ if (changedProperties.has("targetElement") && this.targetElement) {
136
+ if (
137
+ isEFTemporal(this.targetElement) &&
138
+ !isControllable(this.targetElement)
139
+ ) {
140
+ console.warn(
141
+ "Control element is targeting a non-root temporal element without playbackController. " +
142
+ "Controls can only target root temporal elements (not nested within a timegroup). " +
143
+ "Target element:",
144
+ this.targetElement,
145
+ );
146
+ }
147
+ this.#subscribeToTargetContext();
148
+ }
149
+
150
+ if (changedProperties.has("target")) {
151
+ if (this.target && !this.#targetController) {
152
+ this.#targetController = new TargetController(
153
+ this as any as LitElement & {
154
+ targetElement: Element | null;
155
+ target: string;
156
+ },
157
+ );
158
+ }
159
+ }
160
+ }
161
+
162
+ disconnectedCallback() {
163
+ super.disconnectedCallback();
164
+ this.#contextUnsubscribe?.();
165
+ for (const unsubscribe of this.#additionalContextUnsubscribes.values()) {
166
+ unsubscribe();
167
+ }
168
+ this.#additionalContextUnsubscribes.clear();
169
+ if (this.#contextRequestHandler) {
170
+ this.removeEventListener(
171
+ "context-request",
172
+ this.#contextRequestHandler,
173
+ true,
174
+ );
175
+ }
176
+ }
177
+ }
178
+
179
+ return TargetOrContextClass as Constructor<{
180
+ target: string;
181
+ targetElement: ControllableInterface | null;
182
+ effectiveContext: ControllableInterface | null;
183
+ }> &
184
+ T;
185
+ }