@editframe/elements 0.11.0-beta.9 → 0.12.0-beta.2

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 (48) hide show
  1. package/dist/EF_FRAMEGEN.d.ts +8 -15
  2. package/dist/assets/src/MP4File.js +5 -3
  3. package/dist/elements/EFCaptions.d.ts +50 -6
  4. package/dist/elements/EFMedia.d.ts +1 -1
  5. package/dist/elements/EFTimegroup.browsertest.d.ts +4 -0
  6. package/dist/elements/EFTimegroup.d.ts +23 -2
  7. package/dist/elements/EFWaveform.d.ts +15 -11
  8. package/dist/elements/src/EF_FRAMEGEN.js +24 -26
  9. package/dist/elements/src/elements/EFCaptions.js +295 -42
  10. package/dist/elements/src/elements/EFImage.js +0 -6
  11. package/dist/elements/src/elements/EFMedia.js +0 -5
  12. package/dist/elements/src/elements/EFTemporal.js +13 -10
  13. package/dist/elements/src/elements/EFTimegroup.js +37 -12
  14. package/dist/elements/src/elements/EFVideo.js +1 -4
  15. package/dist/elements/src/elements/EFWaveform.js +250 -143
  16. package/dist/elements/src/gui/ContextMixin.js +36 -7
  17. package/dist/elements/src/gui/EFScrubber.js +142 -0
  18. package/dist/elements/src/gui/EFTimeDisplay.js +81 -0
  19. package/dist/elements/src/gui/EFTogglePlay.js +14 -14
  20. package/dist/elements/src/gui/EFWorkbench.js +1 -24
  21. package/dist/elements/src/gui/TWMixin.css.js +1 -1
  22. package/dist/elements/src/index.js +8 -1
  23. package/dist/gui/ContextMixin.d.ts +2 -1
  24. package/dist/gui/EFScrubber.d.ts +23 -0
  25. package/dist/gui/EFTimeDisplay.d.ts +17 -0
  26. package/dist/gui/EFTogglePlay.d.ts +1 -1
  27. package/dist/gui/EFWorkbench.d.ts +0 -1
  28. package/dist/index.d.ts +3 -1
  29. package/dist/style.css +6 -801
  30. package/package.json +2 -2
  31. package/src/elements/EFCaptions.browsertest.ts +6 -6
  32. package/src/elements/EFCaptions.ts +325 -56
  33. package/src/elements/EFImage.browsertest.ts +4 -17
  34. package/src/elements/EFImage.ts +0 -6
  35. package/src/elements/EFMedia.browsertest.ts +8 -19
  36. package/src/elements/EFMedia.ts +1 -6
  37. package/src/elements/EFTemporal.browsertest.ts +14 -0
  38. package/src/elements/EFTemporal.ts +14 -0
  39. package/src/elements/EFTimegroup.browsertest.ts +37 -0
  40. package/src/elements/EFTimegroup.ts +42 -17
  41. package/src/elements/EFVideo.ts +1 -4
  42. package/src/elements/EFWaveform.ts +339 -314
  43. package/src/gui/ContextMixin.browsertest.ts +28 -2
  44. package/src/gui/ContextMixin.ts +41 -9
  45. package/src/gui/EFScrubber.ts +145 -0
  46. package/src/gui/EFTimeDisplay.ts +81 -0
  47. package/src/gui/EFTogglePlay.ts +21 -21
  48. package/src/gui/EFWorkbench.ts +3 -36
@@ -2,10 +2,13 @@ import { LitElement } from "lit";
2
2
  import { customElement } from "lit/decorators/custom-element.js";
3
3
  import { describe, expect, test, vi } from "vitest";
4
4
 
5
- import { ContextMixin } from "./ContextMixin.ts";
6
5
  import { consume } from "@lit/context";
6
+ import { ContextMixin } from "./ContextMixin.ts";
7
7
  import { apiHostContext } from "./apiHostContext.ts";
8
8
 
9
+ // Required to test timeupdate event, we need a duration, and timegroups are a quick way to do that
10
+ import "../elements/EFTimegroup.ts";
11
+
9
12
  @customElement("test-context")
10
13
  class TestContext extends ContextMixin(LitElement) {}
11
14
 
@@ -49,7 +52,6 @@ describe("ContextMixin", () => {
49
52
  expect(element.apiHost).toBe("test2");
50
53
  });
51
54
  });
52
-
53
55
  describe("Playback", () => {
54
56
  test("should start playback", () => {
55
57
  const element = document.createElement("test-context");
@@ -78,4 +80,28 @@ describe("ContextMixin", () => {
78
80
  expect(playbackSpy).toHaveBeenCalled();
79
81
  });
80
82
  });
83
+
84
+ test("Time update event when the currentTimeMs changed", async () => {
85
+ const timegroup = document.createElement("ef-timegroup");
86
+ timegroup.mode = "fixed";
87
+ timegroup.duration = "10s";
88
+
89
+ const preview = document.createElement("test-context");
90
+ preview.append(timegroup);
91
+ document.body.append(preview);
92
+
93
+ type CurrentTimeEvent = CustomEvent<{ currentTimeMs: number }>;
94
+
95
+ // Expect the timeupdate event to be dispatched
96
+ const timeupdatePromise = new Promise<CurrentTimeEvent>((resolve) => {
97
+ preview.addEventListener(
98
+ "timeupdate",
99
+ (event: Event) => resolve(event as CurrentTimeEvent),
100
+ { once: true },
101
+ );
102
+ });
103
+ preview.currentTimeMs = 1000;
104
+ const event = await timeupdatePromise;
105
+ expect(event.detail.currentTimeMs).toBe(1000);
106
+ });
81
107
  });
@@ -1,17 +1,17 @@
1
- import type { LitElement } from "lit";
2
1
  import { provide } from "@lit/context";
2
+ import type { LitElement } from "lit";
3
3
  import { property, state } from "lit/decorators.js";
4
4
 
5
- import { focusContext, type FocusContext } from "./focusContext.ts";
6
- import { focusedElementContext } from "./focusedElementContext.ts";
7
- import { fetchContext } from "./fetchContext.ts";
8
5
  import { createRef } from "lit/directives/ref.js";
9
- import { loopContext, playingContext } from "./playingContext.ts";
10
6
  import type { EFTimegroup } from "../elements/EFTimegroup.ts";
11
- import { efContext } from "./efContext.ts";
12
7
  import { apiHostContext } from "./apiHostContext.ts";
8
+ import { efContext } from "./efContext.ts";
9
+ import { fetchContext } from "./fetchContext.ts";
10
+ import { type FocusContext, focusContext } from "./focusContext.ts";
11
+ import { focusedElementContext } from "./focusedElementContext.ts";
12
+ import { loopContext, playingContext } from "./playingContext.ts";
13
13
 
14
- export declare class ContextMixinInterface {
14
+ export declare class ContextMixinInterface extends LitElement {
15
15
  signingURL?: string;
16
16
  apiHost?: string;
17
17
  rendering: boolean;
@@ -26,9 +26,21 @@ export declare class ContextMixinInterface {
26
26
  pause(): void;
27
27
  }
28
28
 
29
+ const contextMixinSymbol = Symbol("contextMixin");
30
+
31
+ export function isContextMixin(value: any): value is ContextMixinInterface {
32
+ return (
33
+ typeof value === "object" &&
34
+ value !== null &&
35
+ contextMixinSymbol in value.constructor
36
+ );
37
+ }
38
+
29
39
  type Constructor<T = {}> = new (...args: any[]) => T;
30
40
  export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
31
41
  class ContextElement extends superClass {
42
+ static [contextMixinSymbol] = true;
43
+
32
44
  @provide({ context: focusContext })
33
45
  focusContext = this as FocusContext;
34
46
 
@@ -70,6 +82,8 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
70
82
  Object.assign(init.headers, {
71
83
  authorization: `Bearer ${urlToken}`,
72
84
  });
85
+ } else {
86
+ init.credentials = "include";
73
87
  }
74
88
 
75
89
  return fetch(url, init);
@@ -104,6 +118,9 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
104
118
  stageRef = createRef<HTMLDivElement>();
105
119
  canvasRef = createRef<HTMLSlotElement>();
106
120
 
121
+ #FPS = 30;
122
+ #MS_PER_FRAME = 1000 / this.#FPS;
123
+
107
124
  setStageScale = () => {
108
125
  if (this.isConnected && !this.rendering) {
109
126
  const canvasElement = this.canvasRef.value;
@@ -164,10 +181,20 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
164
181
  this.stopPlayback();
165
182
  }
166
183
  }
167
-
168
184
  if (changedProperties.has("currentTimeMs") && this.targetTimegroup) {
169
185
  if (this.targetTimegroup.currentTimeMs !== this.currentTimeMs) {
170
186
  this.targetTimegroup.currentTimeMs = this.currentTimeMs;
187
+ if (this.isConnected) {
188
+ this.dispatchEvent(
189
+ new CustomEvent("timeupdate", {
190
+ detail: {
191
+ currentTimeMs: this.currentTimeMs,
192
+ progress:
193
+ this.currentTimeMs / this.targetTimegroup.durationMs,
194
+ },
195
+ }),
196
+ );
197
+ }
171
198
  }
172
199
  }
173
200
  super.update(changedProperties);
@@ -190,8 +217,13 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
190
217
  #AUDIO_PLAYBACK_SLICE_MS = 1000;
191
218
 
192
219
  #syncPlayheadToAudioContext(target: EFTimegroup, startMs: number) {
193
- this.currentTimeMs =
220
+ const rawTimeMs =
194
221
  startMs + (this.#playbackAudioContext?.currentTime ?? 0) * 1000;
222
+ const nextTimeMs =
223
+ Math.round(rawTimeMs / this.#MS_PER_FRAME) * this.#MS_PER_FRAME;
224
+ if (nextTimeMs !== this.currentTimeMs) {
225
+ this.currentTimeMs = nextTimeMs;
226
+ }
195
227
  this.#playbackAnimationFrameRequest = requestAnimationFrame(() => {
196
228
  this.#syncPlayheadToAudioContext(target, startMs);
197
229
  });
@@ -0,0 +1,145 @@
1
+ import { consume } from "@lit/context";
2
+ import { LitElement, css, html } from "lit";
3
+ import { customElement, state } from "lit/decorators.js";
4
+
5
+ import { ref } from "lit/directives/ref.js";
6
+ import type { ContextMixinInterface } from "./ContextMixin.ts";
7
+ import { efContext } from "./efContext.ts";
8
+ import { playingContext } from "./playingContext.ts";
9
+
10
+ @customElement("ef-scrubber")
11
+ export class EFScrubber extends LitElement {
12
+ static styles = [
13
+ css`
14
+ :host {
15
+ --ef-scrubber-height: 4px;
16
+ --ef-scrubber-background: rgb(209 213 219);
17
+ --ef-scrubber-progress-color: rgb(37 99 235);
18
+ --ef-scrubber-handle-size: 12px;
19
+ display: block;
20
+ width: 100%;
21
+ }
22
+
23
+ .scrubber {
24
+ width: 100%;
25
+ height: var(--ef-scrubber-height);
26
+ background: var(--ef-scrubber-background);
27
+ position: relative;
28
+ cursor: pointer;
29
+ border-radius: 2px;
30
+ }
31
+
32
+ .progress {
33
+ position: absolute;
34
+ height: 100%;
35
+ background: var(--ef-scrubber-progress-color);
36
+ border-radius: 2px;
37
+ }
38
+
39
+ .handle {
40
+ position: absolute;
41
+ width: var(--ef-scrubber-handle-size);
42
+ height: var(--ef-scrubber-handle-size);
43
+ background: var(--ef-scrubber-progress-color);
44
+ border-radius: 50%;
45
+ top: 50%;
46
+ transform: translate(-50%, -50%);
47
+ cursor: grab;
48
+ }
49
+
50
+ /* Add CSS Shadow Parts */
51
+ ::part(scrubber) { }
52
+ ::part(progress) { }
53
+ ::part(handle) { }
54
+ `,
55
+ ];
56
+
57
+ @consume({ context: efContext, subscribe: true })
58
+ context?: ContextMixinInterface | null;
59
+
60
+ @consume({ context: playingContext, subscribe: true })
61
+ playing = false;
62
+
63
+ @state()
64
+ private lastTimeUpdateProgress = 0;
65
+
66
+ @state()
67
+ private scrubProgress = 0;
68
+
69
+ @state()
70
+ private isDragging = false;
71
+
72
+ private scrubberRef?: HTMLElement;
73
+
74
+ private updateProgress(e: MouseEvent) {
75
+ if (!this.context || !this.scrubberRef) return;
76
+
77
+ const rect = this.scrubberRef.getBoundingClientRect();
78
+ const x = e.clientX - rect.left;
79
+ const progress = Math.max(0, Math.min(1, x / rect.width));
80
+
81
+ this.scrubProgress = progress;
82
+ this.context.currentTimeMs =
83
+ progress * (this.context.targetTimegroup?.durationMs ?? 0);
84
+ }
85
+
86
+ private boundHandleMouseDown = (e: MouseEvent) => {
87
+ this.isDragging = true;
88
+ e.preventDefault();
89
+ this.updateProgress(e);
90
+ };
91
+
92
+ private boundHandleMouseMove = (e: MouseEvent) => {
93
+ if (this.isDragging) {
94
+ this.updateProgress(e);
95
+ }
96
+ };
97
+
98
+ private boundHandleMouseUp = () => {
99
+ this.isDragging = false;
100
+ };
101
+
102
+ render() {
103
+ const displayProgress = this.isDragging
104
+ ? this.scrubProgress
105
+ : this.lastTimeUpdateProgress;
106
+
107
+ return html`
108
+ <div
109
+ ${ref((el) => {
110
+ this.scrubberRef = el as HTMLElement;
111
+ })}
112
+ part="scrubber"
113
+ class="scrubber"
114
+ @mousedown=${this.boundHandleMouseDown}
115
+ >
116
+ <div class="progress" style="width: ${displayProgress * 100}%"></div>
117
+ <div class="handle" style="left: ${displayProgress * 100}%"></div>
118
+ </div>
119
+ `;
120
+ }
121
+
122
+ connectedCallback() {
123
+ super.connectedCallback();
124
+ window.addEventListener("mouseup", this.boundHandleMouseUp);
125
+ window.addEventListener("mousemove", this.boundHandleMouseMove);
126
+
127
+ if (this.context) {
128
+ this.context.addEventListener("timeupdate", (e: Event) => {
129
+ this.lastTimeUpdateProgress = (e as CustomEvent).detail.progress;
130
+ });
131
+ }
132
+ }
133
+
134
+ disconnectedCallback() {
135
+ super.disconnectedCallback();
136
+ window.removeEventListener("mouseup", this.boundHandleMouseUp);
137
+ window.removeEventListener("mousemove", this.boundHandleMouseMove);
138
+ }
139
+ }
140
+
141
+ declare global {
142
+ interface HTMLElementTagNameMap {
143
+ "ef-scrubber": EFScrubber;
144
+ }
145
+ }
@@ -0,0 +1,81 @@
1
+ import { consume } from "@lit/context";
2
+ import { LitElement, css, html } from "lit";
3
+ import { customElement } from "lit/decorators.js";
4
+ import type { ContextMixinInterface } from "./ContextMixin.ts";
5
+ import { efContext } from "./efContext.ts";
6
+
7
+ @customElement("ef-time-display")
8
+ export class EFTimeDisplay extends LitElement {
9
+ static styles = css`
10
+ :host {
11
+ display: inline-block;
12
+ font-family: var(--ef-font-family, system-ui);
13
+ font-size: var(--ef-font-size-xs, 0.75rem);
14
+ color: var(--ef-text-color, rgb(75 85 99));
15
+ white-space: nowrap;
16
+ }
17
+ `;
18
+
19
+ @consume({ context: efContext, subscribe: true })
20
+ context?: ContextMixinInterface | null;
21
+
22
+ private _onTimeUpdate = () => {
23
+ this.requestUpdate();
24
+ };
25
+
26
+ connectedCallback(): void {
27
+ super.connectedCallback();
28
+ this.context?.addEventListener(
29
+ "timeupdate",
30
+ this._onTimeUpdate as EventListener,
31
+ );
32
+ }
33
+
34
+ protected updated(changedProperties: Map<PropertyKey, unknown>): void {
35
+ if (changedProperties.has("context")) {
36
+ // Clean up old listener
37
+ const oldContext = changedProperties.get(
38
+ "context",
39
+ ) as ContextMixinInterface | null;
40
+ oldContext?.removeEventListener(
41
+ "timeupdate",
42
+ this._onTimeUpdate as EventListener,
43
+ );
44
+
45
+ // Add new listener
46
+ this.context?.addEventListener(
47
+ "timeupdate",
48
+ this._onTimeUpdate as EventListener,
49
+ );
50
+ }
51
+ }
52
+
53
+ disconnectedCallback(): void {
54
+ this.context?.removeEventListener("timeupdate", this._onTimeUpdate);
55
+ super.disconnectedCallback();
56
+ }
57
+
58
+ private formatTime(ms: number): string {
59
+ const totalSeconds = Math.floor(ms / 1000);
60
+ const minutes = Math.floor(totalSeconds / 60);
61
+ const seconds = totalSeconds % 60;
62
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
63
+ }
64
+
65
+ render() {
66
+ const currentTime = this.context?.currentTimeMs ?? 0;
67
+ const totalTime = this.context?.targetTimegroup?.durationMs ?? 0;
68
+
69
+ return html`
70
+ <span part="time">
71
+ ${this.formatTime(currentTime)} / ${this.formatTime(totalTime)}
72
+ </span>
73
+ `;
74
+ }
75
+ }
76
+
77
+ declare global {
78
+ interface HTMLElementTagNameMap {
79
+ "ef-time-display": EFTimeDisplay;
80
+ }
81
+ }
@@ -1,9 +1,10 @@
1
1
  import { consume } from "@lit/context";
2
2
  import { LitElement, css, html } from "lit";
3
- import { customElement, property, state } from "lit/decorators.js";
3
+ import { customElement, property } from "lit/decorators.js";
4
4
 
5
5
  import type { ContextMixinInterface } from "./ContextMixin.ts";
6
6
  import { efContext } from "./efContext.ts";
7
+ import { playingContext } from "./playingContext.ts";
7
8
 
8
9
  @customElement("ef-toggle-play")
9
10
  export class EFTogglePlay extends LitElement {
@@ -19,41 +20,40 @@ export class EFTogglePlay extends LitElement {
19
20
  @consume({ context: efContext, subscribe: true })
20
21
  context?: ContextMixinInterface | null;
21
22
 
23
+ @consume({ context: playingContext, subscribe: true })
24
+ playing = false;
25
+
22
26
  @property({ type: String })
23
27
  play = '<button class="text-2xl cursor-pointer">▶️</button>';
24
28
 
25
29
  @property({ type: String })
26
30
  pause = '<button class="text-2xl cursor-pointer">⏸️</button>';
27
31
 
28
- @state()
29
- playing = false;
30
-
31
32
  render() {
32
33
  return html`
33
34
  <div
34
- @click=${() => {
35
- if (this.context) {
36
- if (this.context.playing) {
37
- this.context.pause();
38
- this.playing = false;
39
- } else {
40
- this.context.play();
41
- this.playing = true;
35
+ @click=${() => {
36
+ if (this.context) {
37
+ if (this.playing) {
38
+ this.context.pause();
39
+ } else {
40
+ this.context.play();
41
+ }
42
42
  }
43
+ }}
44
+ >
45
+ ${
46
+ this.playing
47
+ ? html`<slot name="pause"></slot>`
48
+ : html`<slot name="play"></slot>`
43
49
  }
44
- }}>
45
- ${
46
- this.playing
47
- ? html`<slot name="play"></slot>`
48
- : html`<slot name="pause"></slot>`
49
- }
50
- </div>`;
50
+ </div>
51
+ `;
51
52
  }
52
53
 
53
54
  togglePlay() {
54
- this.requestUpdate();
55
55
  if (this.context) {
56
- if (this.context.playing) {
56
+ if (this.playing) {
57
57
  this.context.pause();
58
58
  } else {
59
59
  this.context.play();
@@ -1,12 +1,9 @@
1
- import { LitElement, html, css, type PropertyValueMap } from "lit";
2
- import { TaskStatus } from "@lit/task";
1
+ import { LitElement, type PropertyValueMap, css, html } from "lit";
3
2
  import { customElement, eventOptions } from "lit/decorators.js";
4
- import { ref, createRef } from "lit/directives/ref.js";
3
+ import { createRef, ref } from "lit/directives/ref.js";
5
4
 
6
- import { deepGetTemporalElements } from "../elements/EFTemporal.ts";
7
- import { TWMixin } from "./TWMixin.ts";
8
- import { shallowGetTimegroups } from "../elements/EFTimegroup.ts";
9
5
  import { ContextMixin } from "./ContextMixin.ts";
6
+ import { TWMixin } from "./TWMixin.ts";
10
7
 
11
8
  @customElement("ef-workbench")
12
9
  export class EFWorkbench extends ContextMixin(TWMixin(LitElement)) {
@@ -96,36 +93,6 @@ export class EFWorkbench extends ContextMixin(TWMixin(LitElement)) {
96
93
  </div>
97
94
  `;
98
95
  }
99
-
100
- async stepThrough() {
101
- const stepDurationMs = 1000 / 30;
102
- const timegroups = shallowGetTimegroups(this);
103
- const firstGroup = timegroups[0];
104
- if (!firstGroup) {
105
- throw new Error("No temporal elements found");
106
- }
107
- firstGroup.currentTimeMs = 0;
108
-
109
- const temporals = deepGetTemporalElements(this);
110
- const frameCount = Math.ceil(firstGroup.durationMs / stepDurationMs);
111
-
112
- const busyTasks = temporals
113
- .filter((temporal) => temporal.frameTask.status < TaskStatus.COMPLETE)
114
- .map((temporal) => temporal.frameTask);
115
-
116
- await Promise.all(busyTasks.map((task) => task.taskComplete));
117
-
118
- for (let i = 0; i < frameCount; i++) {
119
- firstGroup.currentTimeMs = i * stepDurationMs;
120
- await new Promise<void>(queueMicrotask);
121
- const busyTasks = temporals
122
- .filter((temporal) => temporal.frameTask.status < TaskStatus.COMPLETE)
123
- .map((temporal) => temporal.frameTask);
124
-
125
- await Promise.all(busyTasks.map((task) => task.taskComplete));
126
- await new Promise((resolve) => requestAnimationFrame(resolve));
127
- }
128
- }
129
96
  }
130
97
 
131
98
  declare global {