@smoove/player 0.1.1

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.
package/dist/player.js ADDED
@@ -0,0 +1,966 @@
1
+ import { LitElement, html, svg } from "lit";
2
+ import { createContext } from "@lit/context";
3
+ //#region src/context.ts
4
+ /**
5
+ * Context token carrying the {@link PlayerApi}. `<smoove-player>` is the provider;
6
+ * descendant controls may consume it with `@lit/context`'s `ContextConsumer`.
7
+ * In light DOM the underlying `context-request` event bubbles through ordinary
8
+ * DOM ancestors, so no shadow boundary is needed. Controls in this package use
9
+ * the simpler {@link getPlayerApi} (`closest`) lookup, which also works when a
10
+ * control is used without a context consumer.
11
+ */
12
+ var playerContext = createContext(Symbol("smoove-player"));
13
+ /** Resolve the nearest ancestor `<smoove-player>` as a {@link PlayerApi}. */
14
+ function getPlayerApi(el) {
15
+ return el.closest("smoove-player");
16
+ }
17
+ //#endregion
18
+ //#region src/base.ts
19
+ /**
20
+ * Base class for **leaf** controls (play button, time, volume, …). Renders its
21
+ * own markup into light DOM (it has no user children, so rendering into `this`
22
+ * is safe and lets users style with plain selectors). Resolves the ancestor
23
+ * `<smoove-player>` on connect and offers {@link watch} to re-render when a player
24
+ * signal changes.
25
+ */
26
+ var SmooveControl = class extends LitElement {
27
+ /** The resolved player, or `null` if used outside a `<smoove-player>`. */
28
+ api = null;
29
+ _unsubs = [];
30
+ createRenderRoot() {
31
+ return this;
32
+ }
33
+ connectedCallback() {
34
+ super.connectedCallback();
35
+ this.api = getPlayerApi(this);
36
+ if (this.api) this.bind(this.api);
37
+ }
38
+ disconnectedCallback() {
39
+ super.disconnectedCallback();
40
+ for (const u of this._unsubs) u();
41
+ this._unsubs = [];
42
+ this.api = null;
43
+ }
44
+ /** Subscribe to a player signal and re-render this control on every change. */
45
+ watch(sig) {
46
+ this._unsubs.push(sig.subscribe(() => this.requestUpdate()));
47
+ }
48
+ /**
49
+ * Declare reactive subscriptions here using {@link watch}. Called once on
50
+ * connect with the resolved {@link PlayerApi}.
51
+ */
52
+ bind(_api) {}
53
+ };
54
+ /**
55
+ * Base class for **layout containers** (overlay, controls, rows, spacer). These
56
+ * wrap user-authored children, so they must never let a framework re-render
57
+ * wipe that content — they are bare custom elements that only exist for
58
+ * semantics + CSS targeting. All styling lives in the opt-in stylesheet.
59
+ */
60
+ var SmooveContainer = class extends HTMLElement {};
61
+ //#endregion
62
+ //#region src/containers.ts
63
+ /**
64
+ * Layout containers. Each is a bare custom element that preserves its
65
+ * user-authored children and is styled entirely by the opt-in stylesheet.
66
+ *
67
+ * - `<smoove-player-overlay>` — centered overlay layer above the video.
68
+ * - `<smoove-player-controls>` — the control bar (one or more rows).
69
+ * - `<smoove-player-controls-row>` — a flex row of controls.
70
+ * - `<smoove-player-space grow>` — a spacer; `grow` makes it consume free space.
71
+ */
72
+ var SmoovePlayerOverlay = class extends SmooveContainer {};
73
+ var SmoovePlayerControls = class extends SmooveContainer {};
74
+ var SmoovePlayerControlsRow = class extends SmooveContainer {};
75
+ var SmoovePlayerSpace = class extends SmooveContainer {};
76
+ var REGISTRY = [
77
+ ["smoove-player-overlay", SmoovePlayerOverlay],
78
+ ["smoove-player-controls", SmoovePlayerControls],
79
+ ["smoove-player-controls-row", SmoovePlayerControlsRow],
80
+ ["smoove-player-space", SmoovePlayerSpace]
81
+ ];
82
+ for (const [tag, ctor] of REGISTRY) if (!customElements.get(tag)) customElements.define(tag, ctor);
83
+ //#endregion
84
+ //#region src/progress.ts
85
+ var clamp01 = (x) => Math.max(0, Math.min(1, x));
86
+ /**
87
+ * Draggable seek bar. Mirrors the demo studio's scrubber UX: grabbing pauses
88
+ * playback and resumes on release, dragging seeks live.
89
+ */
90
+ var SmoovePlayerProgress = class extends SmooveControl {
91
+ _dragging = false;
92
+ _wasPlaying = false;
93
+ bind(api) {
94
+ this.watch(api.state.frame);
95
+ this.watch(api.state.duration);
96
+ }
97
+ disconnectedCallback() {
98
+ super.disconnectedCallback();
99
+ this._teardownDrag();
100
+ }
101
+ _pct() {
102
+ const total = this.api?.state.duration.get() ?? 0;
103
+ const frame = this.api?.state.frame.get() ?? 0;
104
+ return total > 1 ? clamp01(frame / (total - 1)) : 0;
105
+ }
106
+ _posFromEvent(e) {
107
+ const r = (this.querySelector(".smoove-player__track") ?? this).getBoundingClientRect();
108
+ return r.width > 0 ? clamp01((e.clientX - r.left) / r.width) : 0;
109
+ }
110
+ _seek(p) {
111
+ const total = this.api?.state.duration.get() ?? 0;
112
+ if (total > 1) this.api?.seekTo(Math.round(p * (total - 1)));
113
+ }
114
+ _onMove = (e) => {
115
+ if (this._dragging) this._seek(this._posFromEvent(e));
116
+ };
117
+ _onUp = () => {
118
+ if (!this._dragging) return;
119
+ this._dragging = false;
120
+ this._teardownDrag();
121
+ if (this._wasPlaying) this.api?.play();
122
+ };
123
+ _teardownDrag() {
124
+ window.removeEventListener("pointermove", this._onMove);
125
+ window.removeEventListener("pointerup", this._onUp);
126
+ }
127
+ _onDown(e) {
128
+ if (!this.api) return;
129
+ e.preventDefault();
130
+ this._dragging = true;
131
+ this._wasPlaying = this.api.isPlaying();
132
+ this.api.pause();
133
+ this._seek(this._posFromEvent(e));
134
+ window.addEventListener("pointermove", this._onMove);
135
+ window.addEventListener("pointerup", this._onUp);
136
+ }
137
+ render() {
138
+ const pct = this._pct() * 100;
139
+ return html`<div
140
+ class="smoove-player__progress${this._dragging ? " is-dragging" : ""}"
141
+ @pointerdown=${(e) => this._onDown(e)}
142
+ >
143
+ <div class="smoove-player__track">
144
+ <div class="smoove-player__fill" style=${`width:${pct}%`}></div>
145
+ <div class="smoove-player__knob" style=${`left:${pct}%`}></div>
146
+ </div>
147
+ </div>`;
148
+ }
149
+ };
150
+ if (!customElements.get("smoove-player-progress")) customElements.define("smoove-player-progress", SmoovePlayerProgress);
151
+ //#endregion
152
+ //#region src/icons.ts
153
+ /**
154
+ * Inline-SVG icon set, ported from the demo studio's icon sheet. Each entry is
155
+ * the inner markup of an 18×18 viewBox; {@link icon} wraps it in an `<svg>`.
156
+ * No icon dependency — controls render `${icon("play")}` directly.
157
+ */
158
+ var PATHS = {
159
+ play: svg`<path d="M6 4.5l9 5.5-9 5.5z" fill="currentColor" stroke="none" />`,
160
+ pause: svg`<g fill="currentColor" stroke="none">
161
+ <rect x="5" y="4" width="3.2" height="12" rx="1" />
162
+ <rect x="11.8" y="4" width="3.2" height="12" rx="1" />
163
+ </g>`,
164
+ prev: svg`<g fill="currentColor" stroke="none">
165
+ <path d="M14 5v10l-7-5z" />
166
+ <rect x="4.5" y="5" width="2.2" height="10" rx="1" />
167
+ </g>`,
168
+ next: svg`<g fill="currentColor" stroke="none">
169
+ <path d="M6 5v10l7-5z" />
170
+ <rect x="13.3" y="5" width="2.2" height="10" rx="1" />
171
+ </g>`,
172
+ volume: svg`<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
173
+ <path d="M4 8v4h2.5L11 15.5v-11L6.5 8z" fill="currentColor" stroke="none" />
174
+ <path d="M13.2 7.2a3.6 3.6 0 010 5.6M15 5.4a6 6 0 010 9.2" />
175
+ </g>`,
176
+ mute: svg`<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
177
+ <path d="M4 8v4h2.5L11 15.5v-11L6.5 8z" fill="currentColor" stroke="none" />
178
+ <path d="M13.5 8l3 4M16.5 8l-3 4" />
179
+ </g>`,
180
+ loop: svg`<g fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
181
+ <path d="M5 7h7a3 3 0 013 3M15 13H8a3 3 0 01-3-3" />
182
+ <path d="M13 5l2 2-2 2M7 15l-2-2 2-2" />
183
+ </g>`,
184
+ fullscreen: svg`<path d="M4 7V4h3M16 7V4h-3M4 13v3h3M16 13v3h-3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />`,
185
+ fullscreenExit: svg`<path d="M7 4v3H4M13 4v3h3M7 16v-3H4M13 16v-3h3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />`
186
+ };
187
+ /** Render a named icon as an `<svg>` of the given pixel size. */
188
+ function icon(name, size = 18) {
189
+ return svg`<svg
190
+ width=${size}
191
+ height=${size}
192
+ viewBox="0 0 18 18"
193
+ aria-hidden="true"
194
+ style="display:block;flex:0 0 auto"
195
+ >${PATHS[name] ?? null}</svg>`;
196
+ }
197
+ //#endregion
198
+ //#region src/play-toggle-button.ts
199
+ /** A play/pause toggle button reflecting and driving playback state. */
200
+ var SmoovePlayerPlayToggleButton = class extends SmooveControl {
201
+ bind(api) {
202
+ this.watch(api.state.playing);
203
+ }
204
+ render() {
205
+ const playing = this.api?.state.playing.get() ?? false;
206
+ const label = playing ? "Pause" : "Play";
207
+ return html`<button
208
+ type="button"
209
+ class="smoove-player__btn"
210
+ aria-label=${label}
211
+ title=${label}
212
+ @click=${() => this.api?.toggle()}
213
+ >${icon(playing ? "pause" : "play")}</button>`;
214
+ }
215
+ };
216
+ if (!customElements.get("smoove-player-play-toggle-button")) customElements.define("smoove-player-play-toggle-button", SmoovePlayerPlayToggleButton);
217
+ //#endregion
218
+ //#region src/sound-control.ts
219
+ /**
220
+ * Mute toggle + volume slider. With the `collapsed` attribute the slider is
221
+ * hidden until the control is hovered/focused (styled by the stylesheet).
222
+ */
223
+ var SmoovePlayerSoundControl = class extends SmooveControl {
224
+ static properties = { collapsed: {
225
+ type: Boolean,
226
+ reflect: true
227
+ } };
228
+ bind(api) {
229
+ this.watch(api.state.volume);
230
+ this.watch(api.state.muted);
231
+ }
232
+ _onInput(e) {
233
+ const v = Number(e.target.value);
234
+ this.api?.setVolume(v);
235
+ this.api?.setMuted(v === 0);
236
+ }
237
+ render() {
238
+ const muted = this.api?.state.muted.get() ?? false;
239
+ const volume = this.api?.state.volume.get() ?? 1;
240
+ const off = muted || volume === 0;
241
+ const label = off ? "Unmute" : "Mute";
242
+ return html`<div class="smoove-player__sound">
243
+ <button
244
+ type="button"
245
+ class="smoove-player__btn"
246
+ aria-label=${label}
247
+ title=${label}
248
+ @click=${() => this.api?.toggleMute()}
249
+ >${icon(off ? "mute" : "volume")}</button>
250
+ <input
251
+ class="smoove-player__volume"
252
+ type="range"
253
+ min="0"
254
+ max="1"
255
+ step="0.01"
256
+ aria-label="Volume"
257
+ .value=${String(off ? 0 : volume)}
258
+ @input=${(e) => this._onInput(e)}
259
+ />
260
+ </div>`;
261
+ }
262
+ };
263
+ if (!customElements.get("smoove-player-sound-control")) customElements.define("smoove-player-sound-control", SmoovePlayerSoundControl);
264
+ //#endregion
265
+ //#region src/time.ts
266
+ var fmt = (seconds) => {
267
+ const t = Math.max(0, seconds);
268
+ return `${Math.floor(t / 60)}:${Math.floor(t % 60).toString().padStart(2, "0")}`;
269
+ };
270
+ /** Current time / total duration readout (`m:ss / m:ss`). */
271
+ var SmoovePlayerTime = class extends SmooveControl {
272
+ bind(api) {
273
+ this.watch(api.state.frame);
274
+ this.watch(api.state.duration);
275
+ }
276
+ render() {
277
+ const fps = this.api?.fps ?? 0;
278
+ const frame = this.api?.state.frame.get() ?? 0;
279
+ const total = this.api?.state.duration.get() ?? 0;
280
+ const cur = fps > 0 ? frame / fps : 0;
281
+ const dur = fps > 0 ? total / fps : 0;
282
+ return html`<span class="smoove-player__time"
283
+ ><span class="smoove-player__time-cur">${fmt(cur)}</span
284
+ ><span class="smoove-player__time-sep">/</span
285
+ ><span class="smoove-player__time-dur">${fmt(dur)}</span></span
286
+ >`;
287
+ }
288
+ };
289
+ if (!customElements.get("smoove-player-time")) customElements.define("smoove-player-time", SmoovePlayerTime);
290
+ //#endregion
291
+ //#region src/loop-button.ts
292
+ /** Toggles loop playback. Reflects an `on` attribute when looping. */
293
+ var SmoovePlayerLoopButton = class extends SmooveControl {
294
+ static properties = { on: {
295
+ type: Boolean,
296
+ reflect: true
297
+ } };
298
+ bind(api) {
299
+ this.watch(api.state.loop);
300
+ }
301
+ willUpdate(_changed) {
302
+ this.on = this.api?.state.loop.get() ?? false;
303
+ }
304
+ render() {
305
+ return html`<button
306
+ type="button"
307
+ class="smoove-player__btn"
308
+ aria-label="Loop"
309
+ title="Loop"
310
+ aria-pressed=${this.api?.state.loop.get() ? "true" : "false"}
311
+ @click=${() => this.api?.toggleLoop()}
312
+ >${icon("loop")}</button>`;
313
+ }
314
+ };
315
+ if (!customElements.get("smoove-player-loop-button")) customElements.define("smoove-player-loop-button", SmoovePlayerLoopButton);
316
+ //#endregion
317
+ //#region src/fullscreen-button.ts
318
+ /** Toggles the player in and out of fullscreen. */
319
+ var SmoovePlayerFullscreenButton = class extends SmooveControl {
320
+ bind(api) {
321
+ this.watch(api.state.fullscreen);
322
+ }
323
+ render() {
324
+ const fs = this.api?.state.fullscreen.get() ?? false;
325
+ const label = fs ? "Exit fullscreen" : "Fullscreen";
326
+ return html`<button
327
+ type="button"
328
+ class="smoove-player__btn"
329
+ aria-label=${label}
330
+ title=${label}
331
+ @click=${() => this.api?.toggleFullscreen()}
332
+ >${icon(fs ? "fullscreenExit" : "fullscreen")}</button>`;
333
+ }
334
+ };
335
+ if (!customElements.get("smoove-player-fullscreen-button")) customElements.define("smoove-player-fullscreen-button", SmoovePlayerFullscreenButton);
336
+ //#endregion
337
+ //#region src/default-controls.ts
338
+ /**
339
+ * Build the default control bar used when `<smoove-player controls>` has no
340
+ * user-supplied `<smoove-player-controls>`. It is composed from the same public
341
+ * sub-components, so it inherits the player context and the opt-in styling.
342
+ * Tagged `data-smoove-default` so the player can swap it out if the user later
343
+ * provides their own controls.
344
+ */
345
+ function createDefaultControls() {
346
+ const controls = document.createElement("smoove-player-controls");
347
+ controls.setAttribute("data-smoove-default", "");
348
+ const progressRow = document.createElement("smoove-player-controls-row");
349
+ progressRow.appendChild(document.createElement("smoove-player-progress"));
350
+ const row = document.createElement("smoove-player-controls-row");
351
+ row.appendChild(document.createElement("smoove-player-play-toggle-button"));
352
+ const sound = document.createElement("smoove-player-sound-control");
353
+ sound.setAttribute("collapsed", "");
354
+ row.appendChild(sound);
355
+ row.appendChild(document.createElement("smoove-player-time"));
356
+ const space = document.createElement("smoove-player-space");
357
+ space.setAttribute("grow", "");
358
+ row.appendChild(space);
359
+ row.appendChild(document.createElement("smoove-player-loop-button"));
360
+ row.appendChild(document.createElement("smoove-player-fullscreen-button"));
361
+ controls.appendChild(progressRow);
362
+ controls.appendChild(row);
363
+ return controls;
364
+ }
365
+ //#endregion
366
+ //#region src/signal.ts
367
+ /**
368
+ * Minimal reactive value, shape-compatible with core's `ReadonlySignal`.
369
+ * The player owns its own stable signals (frame, playing, fullscreen, scale,
370
+ * …) so descendant components can subscribe once and keep working even as the
371
+ * underlying `Composition` is swapped in or out.
372
+ */
373
+ function createSignal(initial) {
374
+ let value = initial;
375
+ const listeners = /* @__PURE__ */ new Set();
376
+ return {
377
+ get: () => value,
378
+ set(next) {
379
+ if (Object.is(next, value)) return;
380
+ value = next;
381
+ for (const fn of listeners) fn(value);
382
+ },
383
+ subscribe(listener) {
384
+ listeners.add(listener);
385
+ return () => {
386
+ listeners.delete(listener);
387
+ };
388
+ }
389
+ };
390
+ }
391
+ //#endregion
392
+ //#region src/smoove-player.ts
393
+ var TIMEUPDATE_INTERVAL_MS = 250;
394
+ var now = () => typeof performance?.now === "function" ? performance.now() : Date.now();
395
+ /**
396
+ * Duck-typed `Composition` check — avoids a runtime dependency on `core`
397
+ * (it stays a type-only import). A Composition is a `Konva.Stage`
398
+ * (`setContainer`) that also exposes the engine's `refresh()`.
399
+ */
400
+ function isComposition(v) {
401
+ return typeof v === "object" && v !== null && typeof v.setContainer === "function" && typeof v.refresh === "function";
402
+ }
403
+ /**
404
+ * Resolve a remote module's default export to a live {@link Composition},
405
+ * unwrapping factories (sync/async) and `{ default }` nesting along the way.
406
+ */
407
+ async function resolveComposition(input) {
408
+ let value = input;
409
+ for (let i = 0; i < 5; i++) {
410
+ value = await value;
411
+ if (isComposition(value)) return value;
412
+ if (typeof value === "function") {
413
+ value = value();
414
+ continue;
415
+ }
416
+ if (typeof value === "object" && value !== null && "default" in value) {
417
+ value = value.default;
418
+ continue;
419
+ }
420
+ break;
421
+ }
422
+ throw new Error("[smoove] remote src did not resolve to a Composition — expected a default export of a Composition or a factory returning one");
423
+ }
424
+ /**
425
+ * `<smoove-player>` — the host element. Wraps a {@link Composition} and plays it
426
+ * like an HTML5 `<video>`: letterbox-scales the stage to its box, supports
427
+ * fullscreen and keyboard control, auto-renders a default control bar, and
428
+ * exposes a Remotion-style imperative + event API.
429
+ *
430
+ * It is a plain custom element (not a `LitElement`) so it never re-renders over
431
+ * its user-authored children (overlay / controls). Chrome is injected
432
+ * imperatively; descendant controls read state through {@link PlayerApi}.
433
+ *
434
+ * `composition` is a property (an object), e.g. `el.composition = comp`.
435
+ */
436
+ var SmoovePlayer = class extends HTMLElement {
437
+ static get observedAttributes() {
438
+ return [
439
+ "loop",
440
+ "controls",
441
+ "muted",
442
+ "volume",
443
+ "playbackrate",
444
+ "src"
445
+ ];
446
+ }
447
+ _frame = createSignal(0);
448
+ _playing = createSignal(false);
449
+ _duration = createSignal(1);
450
+ _loop = createSignal(false);
451
+ _rate = createSignal(1);
452
+ _volume = createSignal(1);
453
+ _muted = createSignal(false);
454
+ _fullscreen = createSignal(false);
455
+ _scale = createSignal(1);
456
+ state = {
457
+ frame: this._frame,
458
+ playing: this._playing,
459
+ duration: this._duration,
460
+ loop: this._loop,
461
+ rate: this._rate,
462
+ volume: this._volume,
463
+ muted: this._muted,
464
+ fullscreen: this._fullscreen,
465
+ scale: this._scale
466
+ };
467
+ _comp = null;
468
+ _unsubs = [];
469
+ _loadSeq = 0;
470
+ _prevPlaying = false;
471
+ _lastTimeupdate = 0;
472
+ _stage = null;
473
+ _scaleEl = null;
474
+ _canvasEl = null;
475
+ _resizeObserver = null;
476
+ _mutationObserver = null;
477
+ _appliedPixelRatio = 0;
478
+ get composition() {
479
+ return this._comp;
480
+ }
481
+ set composition(c) {
482
+ this._loadSeq++;
483
+ if (c === this._comp) return;
484
+ this._comp?.stop();
485
+ this._unbind();
486
+ this._appliedPixelRatio = 0;
487
+ this._comp = c ?? null;
488
+ if (this.isConnected && this._comp) this._mount();
489
+ }
490
+ get fps() {
491
+ return this._comp?.fps ?? 0;
492
+ }
493
+ get loop() {
494
+ return this.hasAttribute("loop");
495
+ }
496
+ set loop(v) {
497
+ this.toggleAttribute("loop", v);
498
+ }
499
+ get controls() {
500
+ return this.hasAttribute("controls");
501
+ }
502
+ set controls(v) {
503
+ this.toggleAttribute("controls", v);
504
+ }
505
+ get autoPlay() {
506
+ return this.hasAttribute("autoplay");
507
+ }
508
+ set autoPlay(v) {
509
+ this.toggleAttribute("autoplay", v);
510
+ }
511
+ get clickToPlay() {
512
+ return !this.hasAttribute("no-click-to-play");
513
+ }
514
+ get spaceKey() {
515
+ return !this.hasAttribute("no-space-key");
516
+ }
517
+ get keyboard() {
518
+ return !this.hasAttribute("no-keyboard");
519
+ }
520
+ get doubleClickFullscreen() {
521
+ return this.hasAttribute("double-click-fullscreen");
522
+ }
523
+ get initialFrame() {
524
+ const a = this.getAttribute("initialframe");
525
+ return a == null ? 0 : Number(a);
526
+ }
527
+ get src() {
528
+ return this.getAttribute("src");
529
+ }
530
+ set src(v) {
531
+ if (v == null) this.removeAttribute("src");
532
+ else this.setAttribute("src", v);
533
+ }
534
+ connectedCallback() {
535
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "0");
536
+ this._ensureChrome();
537
+ this._resizeObserver ??= new ResizeObserver(() => this._layout());
538
+ this._resizeObserver.observe(this);
539
+ this._mutationObserver ??= new MutationObserver(() => this._reconcileControls());
540
+ this._mutationObserver.observe(this, { childList: true });
541
+ document.addEventListener("fullscreenchange", this._onFullscreenChange);
542
+ this.addEventListener("keydown", this._onKeyDown);
543
+ if (this._comp) this._mount();
544
+ else if (this.src) this._loadFromSrc(this.src);
545
+ this._reconcileControls();
546
+ }
547
+ disconnectedCallback() {
548
+ this._unbind();
549
+ this._resizeObserver?.disconnect();
550
+ this._mutationObserver?.disconnect();
551
+ document.removeEventListener("fullscreenchange", this._onFullscreenChange);
552
+ this.removeEventListener("keydown", this._onKeyDown);
553
+ this._stage?.removeEventListener("click", this._onStageClick);
554
+ this._stage?.removeEventListener("dblclick", this._onStageDblClick);
555
+ this._appliedPixelRatio = 0;
556
+ this._comp?.stop();
557
+ this._comp?.suspend();
558
+ this._stage?.remove();
559
+ this._stage = null;
560
+ this._scaleEl = null;
561
+ this._canvasEl = null;
562
+ }
563
+ attributeChangedCallback(name, _old, value) {
564
+ if (name === "src") {
565
+ if (this.isConnected && value) this._loadFromSrc(value);
566
+ return;
567
+ }
568
+ const comp = this._comp;
569
+ if (!comp) return;
570
+ switch (name) {
571
+ case "loop":
572
+ comp.setLoop(this.loop);
573
+ break;
574
+ case "muted":
575
+ comp.mixer.setMuted(this.hasAttribute("muted"));
576
+ break;
577
+ case "volume":
578
+ if (this.hasAttribute("volume")) comp.mixer.setVolume(Number(this.getAttribute("volume")));
579
+ break;
580
+ case "playbackrate":
581
+ if (this.hasAttribute("playbackrate")) comp.setPlaybackRate(Number(this.getAttribute("playbackrate")));
582
+ break;
583
+ case "controls":
584
+ this._reconcileControls();
585
+ break;
586
+ }
587
+ }
588
+ _ensureChrome() {
589
+ if (this._stage) return;
590
+ if (getComputedStyle(this).position === "static") this.style.position = "relative";
591
+ const stage = document.createElement("div");
592
+ stage.className = "smoove-player__stage";
593
+ stage.style.cssText = "position:absolute;inset:0;overflow:hidden";
594
+ const scale = document.createElement("div");
595
+ scale.className = "smoove-player__scale";
596
+ scale.style.cssText = "position:absolute;top:0;left:0;transform-origin:top left";
597
+ const canvas = document.createElement("div");
598
+ canvas.className = "smoove-player__canvas";
599
+ canvas.style.cssText = "width:100%;height:100%";
600
+ scale.appendChild(canvas);
601
+ stage.appendChild(scale);
602
+ this.insertBefore(stage, this.firstChild);
603
+ this._stage = stage;
604
+ this._scaleEl = scale;
605
+ this._canvasEl = canvas;
606
+ stage.addEventListener("click", this._onStageClick);
607
+ stage.addEventListener("dblclick", this._onStageDblClick);
608
+ }
609
+ _mount() {
610
+ const comp = this._comp;
611
+ if (!comp) return;
612
+ this._ensureChrome();
613
+ if (!this._canvasEl) return;
614
+ this._canvasEl.replaceChildren();
615
+ comp.setContainer(this._canvasEl);
616
+ comp.resume();
617
+ if (this.hasAttribute("loop")) comp.setLoop(true);
618
+ if (this.hasAttribute("muted")) comp.mixer.setMuted(true);
619
+ if (this.hasAttribute("volume")) comp.mixer.setVolume(Number(this.getAttribute("volume")));
620
+ if (this.hasAttribute("playbackrate")) comp.setPlaybackRate(Number(this.getAttribute("playbackrate")));
621
+ this._frame.set(comp.frame.get());
622
+ this._playing.set(comp.isPlaying.get());
623
+ this._duration.set(comp.durationInFrames.get());
624
+ this._loop.set(comp.loop.get());
625
+ this._rate.set(comp.playbackRate.get());
626
+ this._volume.set(comp.mixer.volume.get());
627
+ this._muted.set(comp.mixer.muted.get());
628
+ this._prevPlaying = comp.isPlaying.get();
629
+ this._bind(comp);
630
+ comp.setFrame(this.initialFrame);
631
+ comp.refresh();
632
+ this._layout();
633
+ if (this.autoPlay) try {
634
+ this.play();
635
+ } catch (error) {
636
+ this._emit("error", { error });
637
+ }
638
+ }
639
+ _bind(comp) {
640
+ this._unsubs.push(comp.frame.subscribe((frame) => {
641
+ this._frame.set(frame);
642
+ this._emit("frameupdate", { frame });
643
+ const t = now();
644
+ const last = comp.durationInFrames.get() - 1;
645
+ if (t - this._lastTimeupdate >= TIMEUPDATE_INTERVAL_MS || frame >= last || frame <= 0) {
646
+ this._lastTimeupdate = t;
647
+ const fps = comp.fps;
648
+ const total = comp.durationInFrames.get();
649
+ this._emit("timeupdate", {
650
+ frame,
651
+ time: fps > 0 ? frame / fps : 0,
652
+ durationInFrames: total,
653
+ durationInSeconds: fps > 0 ? total / fps : 0
654
+ });
655
+ }
656
+ }), comp.isPlaying.subscribe((playing) => {
657
+ this._playing.set(playing);
658
+ const frame = comp.frame.get();
659
+ if (playing && !this._prevPlaying) this._emit("play", { frame });
660
+ else if (!playing && this._prevPlaying) {
661
+ const last = comp.durationInFrames.get() - 1;
662
+ const rate = comp.playbackRate.get();
663
+ const ended = !comp.loop.get() && (rate > 0 && frame >= last || rate < 0 && frame <= 0);
664
+ this._emit(ended ? "ended" : "pause", { frame });
665
+ }
666
+ this._prevPlaying = playing;
667
+ }), comp.durationInFrames.subscribe((d) => this._duration.set(d)), comp.loop.subscribe((l) => this._loop.set(l)), comp.playbackRate.subscribe((rate) => {
668
+ this._rate.set(rate);
669
+ this._emit("ratechange", { playbackRate: rate });
670
+ }), comp.mixer.volume.subscribe((volume) => {
671
+ this._volume.set(volume);
672
+ this._emit("volumechange", { volume });
673
+ }), comp.mixer.muted.subscribe((muted) => {
674
+ this._muted.set(muted);
675
+ this._emit("mutechange", { muted });
676
+ }));
677
+ }
678
+ _unbind() {
679
+ for (const u of this._unsubs) u();
680
+ this._unsubs = [];
681
+ }
682
+ /**
683
+ * Dynamically import a remote ESM module and mount its default export. The
684
+ * default export may be a {@link Composition}, a factory returning one (sync
685
+ * or async), or a factory resolving to `{ default: Composition }`.
686
+ *
687
+ * Loads are race-guarded with {@link _loadSeq}: a stale import resolving after
688
+ * a newer `src` — or an imperative `composition =` — is discarded.
689
+ */
690
+ async _loadFromSrc(rawSrc) {
691
+ const seq = ++this._loadSeq;
692
+ let url;
693
+ try {
694
+ url = new URL(rawSrc, document.baseURI).href;
695
+ } catch (error) {
696
+ this._emit("error", { error });
697
+ return;
698
+ }
699
+ this.toggleAttribute("loading", true);
700
+ this._emit("loadstart", { src: url });
701
+ try {
702
+ const mod = await import(
703
+ /* @vite-ignore */
704
+ url
705
+ );
706
+ const comp = await resolveComposition("default" in mod ? mod.default : mod);
707
+ if (seq !== this._loadSeq) return;
708
+ this.toggleAttribute("loading", false);
709
+ this.composition = comp;
710
+ this._emit("loaded", {
711
+ src: url,
712
+ composition: comp
713
+ });
714
+ } catch (error) {
715
+ if (seq !== this._loadSeq) return;
716
+ this.toggleAttribute("loading", false);
717
+ this._emit("error", { error });
718
+ }
719
+ }
720
+ _layout() {
721
+ const comp = this._comp;
722
+ if (!comp || !this._scaleEl) return;
723
+ const boxW = this.clientWidth;
724
+ const boxH = this.clientHeight;
725
+ const compW = comp.width() || 1;
726
+ const compH = comp.height() || 1;
727
+ if (boxW <= 0 || boxH <= 0) return;
728
+ const scale = Math.min(boxW / compW, boxH / compH);
729
+ const offX = (boxW - compW * scale) / 2;
730
+ const offY = (boxH - compH * scale) / 2;
731
+ this._scaleEl.style.width = `${compW}px`;
732
+ this._scaleEl.style.height = `${compH}px`;
733
+ this._scaleEl.style.transform = `translate(${offX}px, ${offY}px) scale(${scale})`;
734
+ if (scale !== this._scale.get()) {
735
+ this._scale.set(scale);
736
+ this._emit("scalechange", { scale });
737
+ }
738
+ this._applyRenderScale(scale);
739
+ }
740
+ /** Author-facing cap on the render pixel ratio. Defaults to the device ratio. */
741
+ get _maxPixelRatio() {
742
+ const dpr = typeof globalThis !== "undefined" ? globalThis.devicePixelRatio || 1 : 1;
743
+ const attr = this.getAttribute("max-pixel-ratio");
744
+ const parsed = attr == null ? NaN : Number(attr);
745
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, dpr) : dpr;
746
+ }
747
+ /**
748
+ * Match the composition's backing canvas resolution to how large it's actually
749
+ * displayed, instead of always rendering at authored resolution × devicePixelRatio.
750
+ *
751
+ * A 1600×900 composition letterboxed into a 375px-wide phone was drawing a
752
+ * 3200×1800 (5.76 MP) canvas every frame and letting CSS scale it down — ~18×
753
+ * overdraw. Here the effective pixel ratio is `displayScale × dpr` (capped at
754
+ * the device ratio, floored so it never goes pathologically blurry), so the
755
+ * backing store tracks on-screen pixels. This is the single biggest win for
756
+ * mobile frame rate; it also keeps oversized compositions from over-rendering.
757
+ */
758
+ _applyRenderScale(scale) {
759
+ const comp = this._comp;
760
+ if (!comp || !Number.isFinite(scale) || scale <= 0) return;
761
+ const dpr = typeof globalThis !== "undefined" ? globalThis.devicePixelRatio || 1 : 1;
762
+ const target = Math.max(.5, Math.min(scale * dpr, this._maxPixelRatio));
763
+ if (Math.abs(target - this._appliedPixelRatio) < .01) return;
764
+ this._appliedPixelRatio = target;
765
+ for (const layer of comp.getLayers()) layer.getCanvas().setPixelRatio(target);
766
+ comp.refresh();
767
+ }
768
+ _onFullscreenChange = () => {
769
+ const fs = document.fullscreenElement === this;
770
+ this._fullscreen.set(fs);
771
+ this.toggleAttribute("fullscreen", fs);
772
+ this._emit("fullscreenchange", { isFullscreen: fs });
773
+ this._layout();
774
+ };
775
+ _onStageClick = () => {
776
+ if (this.clickToPlay) this.toggle();
777
+ };
778
+ _onStageDblClick = () => {
779
+ if (this.doubleClickFullscreen) this.toggleFullscreen();
780
+ };
781
+ _onKeyDown = (e) => {
782
+ if (!this.keyboard) return;
783
+ const target = e.target;
784
+ if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
785
+ switch (e.key) {
786
+ case " ":
787
+ if (this.spaceKey) {
788
+ e.preventDefault();
789
+ this.toggle();
790
+ }
791
+ break;
792
+ case "ArrowLeft":
793
+ e.preventDefault();
794
+ this.stepBy(-1);
795
+ break;
796
+ case "ArrowRight":
797
+ e.preventDefault();
798
+ this.stepBy(1);
799
+ break;
800
+ case "f":
801
+ case "F":
802
+ this.toggleFullscreen();
803
+ break;
804
+ case "l":
805
+ case "L":
806
+ this.toggleLoop();
807
+ break;
808
+ }
809
+ };
810
+ _reconcileControls() {
811
+ if (!this.isConnected) return;
812
+ const hasUserControls = Array.from(this.children).some((c) => c.tagName === "SMOOVE-PLAYER-CONTROLS" && !c.hasAttribute("data-smoove-default"));
813
+ const existingDefault = this.querySelector(":scope > smoove-player-controls[data-smoove-default]");
814
+ if (this.controls && !hasUserControls) {
815
+ if (!existingDefault) this.appendChild(createDefaultControls());
816
+ } else if (existingDefault) existingDefault.remove();
817
+ }
818
+ _emit(type, detail) {
819
+ this.dispatchEvent(new CustomEvent(type, {
820
+ detail,
821
+ bubbles: true,
822
+ composed: true
823
+ }));
824
+ }
825
+ play() {
826
+ const comp = this._comp;
827
+ if (!comp) return;
828
+ if (comp.playbackRate.get() < 0 && comp.frame.get() <= 0) comp.setFrame(comp.durationInFrames.get() - 1);
829
+ try {
830
+ comp.play();
831
+ } catch (error) {
832
+ this._emit("error", { error });
833
+ }
834
+ }
835
+ pause() {
836
+ this._comp?.pause();
837
+ }
838
+ toggle() {
839
+ if (this.isPlaying()) this.pause();
840
+ else this.play();
841
+ }
842
+ stop() {
843
+ this._comp?.stop();
844
+ }
845
+ seekTo(frame) {
846
+ const comp = this._comp;
847
+ if (!comp) return;
848
+ comp.setFrame(frame);
849
+ this._emit("seeked", { frame: comp.frame.get() });
850
+ }
851
+ stepBy(delta) {
852
+ const comp = this._comp;
853
+ if (!comp) return;
854
+ this.seekTo(comp.frame.get() + delta);
855
+ }
856
+ setProps(props) {
857
+ this._comp?.setProps(props);
858
+ }
859
+ getCurrentFrame() {
860
+ return this._comp?.frame.get() ?? 0;
861
+ }
862
+ isPlaying() {
863
+ return this._comp?.isPlaying.get() ?? false;
864
+ }
865
+ setVolume(volume) {
866
+ this._comp?.mixer.setVolume(volume);
867
+ }
868
+ getVolume() {
869
+ return this._comp?.mixer.volume.get() ?? this._volume.get();
870
+ }
871
+ mute() {
872
+ this._comp?.mixer.setMuted(true);
873
+ }
874
+ unmute() {
875
+ this._comp?.mixer.setMuted(false);
876
+ }
877
+ setMuted(muted) {
878
+ this._comp?.mixer.setMuted(muted);
879
+ }
880
+ toggleMute() {
881
+ this._comp?.mixer.setMuted(!this.isMuted());
882
+ }
883
+ isMuted() {
884
+ return this._comp?.mixer.muted.get() ?? this._muted.get();
885
+ }
886
+ setLoop(loop) {
887
+ this.loop = loop;
888
+ this._comp?.setLoop(loop);
889
+ }
890
+ toggleLoop() {
891
+ this.setLoop(!this.isLooping());
892
+ }
893
+ isLooping() {
894
+ return this._comp?.loop.get() ?? this._loop.get();
895
+ }
896
+ setPlaybackRate(rate) {
897
+ this._comp?.setPlaybackRate(rate);
898
+ }
899
+ getPlaybackRate() {
900
+ return this._comp?.playbackRate.get() ?? this._rate.get();
901
+ }
902
+ requestFullscreen(options) {
903
+ return HTMLElement.prototype.requestFullscreen.call(this, options);
904
+ }
905
+ exitFullscreen() {
906
+ return this.isFullscreen() ? document.exitFullscreen() : Promise.resolve();
907
+ }
908
+ toggleFullscreen() {
909
+ if (this.isFullscreen()) this.exitFullscreen();
910
+ else this.requestFullscreen();
911
+ }
912
+ isFullscreen() {
913
+ return document.fullscreenElement === this;
914
+ }
915
+ getScale() {
916
+ return this._scale.get();
917
+ }
918
+ };
919
+ if (!customElements.get("smoove-player")) customElements.define("smoove-player", SmoovePlayer);
920
+ //#endregion
921
+ //#region src/play-button.ts
922
+ var SIZES = {
923
+ small: 28,
924
+ medium: 44,
925
+ large: 72
926
+ };
927
+ /**
928
+ * A large, centered play affordance (for use inside `<smoove-player-overlay>`).
929
+ * `size` is `small | medium | large`. Reflects a `playing` attribute so the
930
+ * stylesheet can fade it out during playback.
931
+ */
932
+ var SmoovePlayerPlayButton = class extends SmooveControl {
933
+ static properties = {
934
+ size: {
935
+ type: String,
936
+ reflect: true
937
+ },
938
+ playing: {
939
+ type: Boolean,
940
+ reflect: true
941
+ }
942
+ };
943
+ bind(api) {
944
+ this.watch(api.state.playing);
945
+ }
946
+ willUpdate(_changed) {
947
+ this.playing = this.api?.state.playing.get() ?? false;
948
+ }
949
+ render() {
950
+ const playing = this.api?.state.playing.get() ?? false;
951
+ const size = SIZES[this.size ?? "medium"] ?? SIZES.medium;
952
+ const label = playing ? "Pause" : "Play";
953
+ return html`<button
954
+ type="button"
955
+ class="smoove-player__overlay-play"
956
+ aria-label=${label}
957
+ title=${label}
958
+ @click=${() => this.api?.toggle()}
959
+ >${icon(playing ? "pause" : "play", size)}</button>`;
960
+ }
961
+ };
962
+ if (!customElements.get("smoove-player-play-button")) customElements.define("smoove-player-play-button", SmoovePlayerPlayButton);
963
+ //#endregion
964
+ export { SmoovePlayer, SmoovePlayerControls, SmoovePlayerControlsRow, SmoovePlayerFullscreenButton, SmoovePlayerLoopButton, SmoovePlayerOverlay, SmoovePlayerPlayButton, SmoovePlayerPlayToggleButton, SmoovePlayerProgress, SmoovePlayerSoundControl, SmoovePlayerSpace, SmoovePlayerTime, createDefaultControls, getPlayerApi, playerContext };
965
+
966
+ //# sourceMappingURL=player.js.map