@miraiclip/player 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 comaniacs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @miraiclip/player
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `<miraiclip-player>`: a framework-free web component that plays a rendered video file or a live [Miraiclip](https://docs.miraiclip.com/) project, with a scrubber, chapters and keyboard control.
4
+
5
+ ```html
6
+ <script type="module">import "@miraiclip/player/define";</script>
7
+
8
+ <!-- a rendered file, chapters from a WebVTT (or JSON) file -->
9
+ <miraiclip-player src="launch.mp4" chapters="launch.chapters.vtt" controls></miraiclip-player>
10
+
11
+ <!-- a live document: chapters come from its markers -->
12
+ <miraiclip-player document="video.miraiclip.json" controls></miraiclip-player>
13
+ ```
14
+
15
+ Live documents need `@miraiclip/core` and `@miraiclip/renderer` installed (loaded on demand; file playback needs neither). Docs: https://docs.miraiclip.com/
@@ -0,0 +1,572 @@
1
+ // src/chapters.ts
2
+ function chaptersFromMarkers(markers, durationS) {
3
+ const list = Object.values(markers ?? {}).sort((a, b) => a.timeUs - b.timeUs || a.id.localeCompare(b.id));
4
+ return list.map((m, i) => {
5
+ const start = m.timeUs / 1e6;
6
+ const next = list[i + 1];
7
+ const end = m.durationUs !== void 0 ? (m.timeUs + m.durationUs) / 1e6 : next ? next.timeUs / 1e6 : Math.max(durationS, start);
8
+ return { id: m.id, label: m.label, start, end };
9
+ });
10
+ }
11
+ function parseVttChapters(vtt) {
12
+ const chapters = [];
13
+ const blocks = vtt.replace(/\r/g, "").split(/\n{2,}/);
14
+ const time = (t) => {
15
+ const parts = t.trim().split(":").map(Number);
16
+ let s = 0;
17
+ for (const p of parts) s = s * 60 + p;
18
+ return s;
19
+ };
20
+ for (const block of blocks) {
21
+ const lines = block.split("\n").filter((l) => l.trim() !== "");
22
+ const at = lines.findIndex((l) => l.includes("-->"));
23
+ if (at < 0) continue;
24
+ const [from, to] = lines[at].split("-->").map((s) => s.trim().split(/\s+/)[0]);
25
+ const label = lines.slice(at + 1).join(" ").trim();
26
+ if (!label) continue;
27
+ chapters.push({ id: at > 0 ? lines[0].trim() : `c${chapters.length + 1}`, label, start: time(from), end: time(to) });
28
+ }
29
+ return chapters.sort((a, b) => a.start - b.start);
30
+ }
31
+ function normalizeChapters(input, durationS) {
32
+ if (!Array.isArray(input)) return [];
33
+ const raw = input.map((c, i) => {
34
+ const o = c;
35
+ const label = String(o["label"] ?? o["title"] ?? "");
36
+ const start = typeof o["start"] === "number" ? o["start"] : typeof o["timeUs"] === "number" ? o["timeUs"] / 1e6 : NaN;
37
+ const end = typeof o["end"] === "number" ? o["end"] : typeof o["durationUs"] === "number" && typeof o["timeUs"] === "number" ? (o["timeUs"] + o["durationUs"]) / 1e6 : void 0;
38
+ return { id: String(o["id"] ?? `c${i + 1}`), label, start, end };
39
+ }).filter((c) => c.label && Number.isFinite(c.start)).sort((a, b) => a.start - b.start);
40
+ return raw.map((c, i) => ({ id: c.id, label: c.label, start: c.start, end: c.end ?? raw[i + 1]?.start ?? Math.max(durationS, c.start) }));
41
+ }
42
+ function chapterAt(chapters, timeS, toleranceS = 0.05) {
43
+ let found;
44
+ for (const c of chapters) if (c.start <= timeS + toleranceS && timeS < c.end) found = c;
45
+ return found;
46
+ }
47
+ function formatTime(seconds) {
48
+ const s = Math.max(0, Math.floor(seconds + 1e-6));
49
+ const h = Math.floor(s / 3600);
50
+ const m = Math.floor(s % 3600 / 60);
51
+ const ss = String(s % 60).padStart(2, "0");
52
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
53
+ }
54
+
55
+ // src/engines.ts
56
+ function createVideoEngine(src, options) {
57
+ const video = document.createElement("video");
58
+ video.src = src;
59
+ video.preload = "metadata";
60
+ video.playsInline = true;
61
+ video.loop = options.loop;
62
+ video.muted = options.muted;
63
+ if (options.poster) video.poster = options.poster;
64
+ video.setAttribute("part", "media");
65
+ const change = [];
66
+ const ended = [];
67
+ const errors = [];
68
+ const emit = () => change.forEach((l) => l());
69
+ for (const type of ["timeupdate", "play", "pause", "durationchange", "loadedmetadata", "seeked"]) video.addEventListener(type, emit);
70
+ video.addEventListener("ended", () => ended.forEach((l) => l()));
71
+ video.addEventListener("error", () => errors.forEach((l) => l(video.error?.message || "the video could not be played")));
72
+ return {
73
+ element: video,
74
+ play: () => void video.play()?.catch?.((e) => errors.forEach((l) => l(e.message))),
75
+ pause: () => video.pause(),
76
+ seek: (s) => {
77
+ video.currentTime = Math.max(0, s);
78
+ emit();
79
+ },
80
+ state: () => ({ playing: !video.paused && !video.ended, timeS: video.currentTime || 0, durationS: Number.isFinite(video.duration) ? video.duration : 0 }),
81
+ setMuted: (m) => {
82
+ video.muted = m;
83
+ },
84
+ onChange: (l) => change.push(l),
85
+ onEnded: (l) => ended.push(l),
86
+ onError: (l) => errors.push(l),
87
+ resize: () => void 0,
88
+ destroy: () => {
89
+ video.pause();
90
+ video.removeAttribute("src");
91
+ video.load();
92
+ }
93
+ };
94
+ }
95
+ function resolveAssetSources(doc, base) {
96
+ if (!base) return doc;
97
+ const copy = JSON.parse(JSON.stringify(doc));
98
+ for (const asset of Object.values(copy.assets)) {
99
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(asset.src)) continue;
100
+ try {
101
+ asset.src = new URL(asset.src, base).href;
102
+ } catch {
103
+ }
104
+ }
105
+ return copy;
106
+ }
107
+ var modules;
108
+ function loadLiveModules() {
109
+ modules ??= Promise.all([import("@miraiclip/core"), import("@miraiclip/renderer")]).then(([core, renderer]) => ({ core, renderer }));
110
+ return modules;
111
+ }
112
+ async function createDocumentEngine(doc, options) {
113
+ const { core, renderer } = await loadLiveModules();
114
+ if (options.loadPlugins && doc.plugins?.length) await renderer.loadDocumentPlugins(doc);
115
+ const project = core.createProject(doc);
116
+ const controller = renderer.createPreviewController(project, { player: { loop: options.loop } });
117
+ if (options.muted) controller.toggleMute();
118
+ const canvas = document.createElement("canvas");
119
+ canvas.setAttribute("part", "media");
120
+ const change = [];
121
+ const ended = [];
122
+ const errors = [];
123
+ let durationUs = 0;
124
+ for (const clip of Object.values(doc.clips)) durationUs = Math.max(durationUs, clip.startUs + clip.durationUs);
125
+ let wasPlaying = false;
126
+ const emit = () => {
127
+ const playing = controller.getState().playing;
128
+ if (wasPlaying && !playing && project.getState().playheadUs >= durationUs - 1) ended.forEach((l) => l());
129
+ wasPlaying = playing;
130
+ change.forEach((l) => l());
131
+ };
132
+ controller.subscribe(() => {
133
+ const error = controller.getState().lastError;
134
+ if (error) errors.forEach((l) => l(error));
135
+ emit();
136
+ });
137
+ const offPlayhead = project.events.on("playhead", emit);
138
+ let attached = false;
139
+ return {
140
+ element: canvas,
141
+ play: () => controller.play(),
142
+ pause: () => controller.pause(),
143
+ seek: (s) => controller.seek(Math.round(Math.max(0, s) * 1e6)),
144
+ state: () => ({ playing: controller.getState().playing, timeS: project.getState().playheadUs / 1e6, durationS: durationUs / 1e6 }),
145
+ setMuted: (m) => {
146
+ if (controller.getState().muted !== m) controller.toggleMute();
147
+ },
148
+ onChange: (l) => change.push(l),
149
+ onEnded: (l) => ended.push(l),
150
+ onError: (l) => errors.push(l),
151
+ resize: (width, height) => {
152
+ if (width <= 0 || height <= 0) return;
153
+ if (!attached) {
154
+ controller.attach(canvas, { width, height });
155
+ attached = true;
156
+ } else controller.resize(width, height);
157
+ },
158
+ destroy: () => {
159
+ offPlayhead();
160
+ controller.destroy();
161
+ }
162
+ };
163
+ }
164
+
165
+ // src/element.ts
166
+ var STYLE = `
167
+ :host{display:block;position:relative;--mc-accent:#22d3ee;--mc-bg:#000;--mc-fg:#fff;--mc-radius:12px;--mc-font:Inter,system-ui,sans-serif;color:var(--mc-fg);font-family:var(--mc-font)}
168
+ :host([hidden]){display:none}
169
+ .frame{position:relative;width:100%;aspect-ratio:var(--mc-aspect,16/9);background:var(--mc-bg);border-radius:var(--mc-radius);overflow:hidden;outline:none}
170
+ .stage,.stage>*{position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:contain}
171
+ .big{position:absolute;inset:0;margin:auto;width:76px;height:76px;border-radius:50%;border:0;background:#000a;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center}
172
+ .big svg{width:34px;height:34px;margin-left:4px}
173
+ :host([data-playing]) .big,:host(:not([data-ready])) .big{display:none}
174
+ .bar{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:10px;padding:28px 12px 10px;background:linear-gradient(transparent,#000c);opacity:0;transition:opacity .2s}
175
+ :host([controls]) .frame:hover .bar,:host([controls]) .frame:focus-within .bar,:host([controls]:not([data-playing])) .bar{opacity:1}
176
+ :host(:not([controls])) .bar{display:none}
177
+ button.icon{background:none;border:0;color:inherit;width:34px;height:34px;padding:5px;cursor:pointer;border-radius:8px;flex:none}
178
+ button.icon:hover,button.icon:focus-visible{background:#ffffff22;outline:none}
179
+ button svg{width:100%;height:100%;fill:currentColor}
180
+ .time{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap;opacity:.9}
181
+ .scrub{position:relative;flex:1;height:22px;cursor:pointer;outline:none;touch-action:none}
182
+ .rail{position:absolute;left:0;right:0;top:9px;height:4px;border-radius:2px;background:#ffffff40}
183
+ .fill{position:absolute;left:0;top:9px;height:4px;border-radius:2px;background:var(--mc-accent);width:0}
184
+ .tick{position:absolute;top:7px;width:2px;height:8px;background:#fff;opacity:.85;border-radius:1px}
185
+ .thumb{position:absolute;top:4px;width:14px;height:14px;margin-left:-7px;border-radius:50%;background:#fff;box-shadow:0 1px 4px #0008;left:0}
186
+ .scrub:focus-visible .thumb{outline:2px solid var(--mc-accent);outline-offset:2px}
187
+ .label{font-size:13px;max-width:30%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.9}
188
+ .menu{position:absolute;right:10px;bottom:56px;max-height:60%;overflow:auto;margin:0;padding:6px;list-style:none;border-radius:10px;background:#111827f2;box-shadow:0 10px 30px #0009;min-width:200px}
189
+ .menu[hidden]{display:none}
190
+ .menu button{display:flex;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:6px;background:none;color:inherit;font:inherit;font-size:14px;text-align:left;cursor:pointer}
191
+ .menu button:hover,.menu button:focus-visible{background:#ffffff1f;outline:none}
192
+ .menu button[aria-current="true"]{color:var(--mc-accent)}
193
+ .menu .t{opacity:.7;font-variant-numeric:tabular-nums}
194
+ .error{position:absolute;left:12px;right:12px;top:12px;padding:8px 12px;border-radius:8px;background:#7f1d1dee;font-size:13px}
195
+ .error[hidden]{display:none}
196
+ `;
197
+ var ICON = {
198
+ play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>',
199
+ pause: '<svg viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>',
200
+ chapters: '<svg viewBox="0 0 24 24"><path d="M4 6h2v2H4zm4 0h12v2H8zM4 11h2v2H4zm4 0h12v2H8zm-4 5h2v2H4zm4 0h12v2H8z"/></svg>',
201
+ sound: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>',
202
+ muted: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9zm16.6 0L19 7.4 16.4 10 13.8 7.4 12.4 9l2.6 2.6-2.6 2.6 1.4 1.4 2.6-2.6 2.6 2.6 1.6-1.6-2.6-2.6z"/></svg>'
203
+ };
204
+ var BaseElement = typeof HTMLElement === "undefined" ? class {
205
+ } : HTMLElement;
206
+ var MiraiclipPlayerElement = class extends BaseElement {
207
+ static get observedAttributes() {
208
+ return ["src", "document", "chapters", "poster", "loop", "muted", "assets-base"];
209
+ }
210
+ root;
211
+ engine;
212
+ loadToken = 0;
213
+ docValue;
214
+ chapterList = [];
215
+ explicitChapters;
216
+ currentChapter;
217
+ resizeObserver;
218
+ dragging = false;
219
+ els;
220
+ constructor() {
221
+ super();
222
+ this.root = this.attachShadow({ mode: "open" });
223
+ this.root.innerHTML = `<style>${STYLE}</style>
224
+ <div class="frame" part="frame" tabindex="0">
225
+ <div class="stage" part="stage"></div>
226
+ <button class="big" part="big-play" aria-label="Play">${ICON.play}</button>
227
+ <div class="bar" part="controls">
228
+ <button class="icon play" aria-label="Play">${ICON.play}</button>
229
+ <span class="time" part="time">0:00 / 0:00</span>
230
+ <div class="scrub" part="scrubber" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
231
+ <div class="rail"></div><div class="fill"></div><div class="ticks"></div><div class="thumb"></div>
232
+ </div>
233
+ <span class="label" part="chapter-label"></span>
234
+ <button class="icon chapters" aria-label="Chapters" aria-haspopup="true" aria-expanded="false" hidden>${ICON.chapters}</button>
235
+ <button class="icon mute" aria-label="Mute">${ICON.sound}</button>
236
+ </div>
237
+ <ol class="menu" part="chapters" hidden></ol>
238
+ <div class="error" part="error" role="alert" hidden></div>
239
+ </div>`;
240
+ const $ = (sel) => this.root.querySelector(sel);
241
+ this.els = {
242
+ frame: $(".frame"),
243
+ stage: $(".stage"),
244
+ big: $(".big"),
245
+ play: $(".play"),
246
+ time: $(".time"),
247
+ scrub: $(".scrub"),
248
+ fill: $(".fill"),
249
+ thumb: $(".thumb"),
250
+ ticks: $(".ticks"),
251
+ label: $(".label"),
252
+ chaptersBtn: $(".chapters"),
253
+ menu: $(".menu"),
254
+ mute: $(".mute"),
255
+ error: $(".error")
256
+ };
257
+ this.wire();
258
+ }
259
+ // ------------------------------------------------------------------ API
260
+ /** A project document to play live (takes precedence over `document`/`src` attributes). */
261
+ get document() {
262
+ return this.docValue;
263
+ }
264
+ set document(doc) {
265
+ this.docValue = doc;
266
+ void this.load();
267
+ }
268
+ /** Chapters in seconds. Set to override markers / the `chapters` attribute. */
269
+ get chapters() {
270
+ return [...this.chapterList];
271
+ }
272
+ set chapters(value) {
273
+ this.explicitChapters = value ? normalizeChapters(value, this.duration) : void 0;
274
+ this.refreshChapters();
275
+ }
276
+ get currentTime() {
277
+ return this.engine?.state().timeS ?? 0;
278
+ }
279
+ set currentTime(seconds) {
280
+ this.seek(seconds);
281
+ }
282
+ get duration() {
283
+ return this.engine?.state().durationS ?? 0;
284
+ }
285
+ get paused() {
286
+ return !(this.engine?.state().playing ?? false);
287
+ }
288
+ get currentChapterInfo() {
289
+ return this.currentChapter;
290
+ }
291
+ play() {
292
+ this.engine?.play();
293
+ }
294
+ pause() {
295
+ this.engine?.pause();
296
+ }
297
+ toggle() {
298
+ if (this.paused) this.play();
299
+ else this.pause();
300
+ }
301
+ seek(seconds) {
302
+ this.engine?.seek(Math.min(Math.max(0, seconds), this.duration || seconds));
303
+ this.render();
304
+ }
305
+ /** Jump to a chapter by id or index. */
306
+ goToChapter(which) {
307
+ const chapter = typeof which === "number" ? this.chapterList[which] : this.chapterList.find((c) => c.id === which);
308
+ if (chapter) this.seek(chapter.start);
309
+ }
310
+ // ------------------------------------------------------------------ lifecycle
311
+ connectedCallback() {
312
+ if (typeof ResizeObserver !== "undefined") {
313
+ this.resizeObserver = new ResizeObserver(() => this.sizeEngine());
314
+ this.resizeObserver.observe(this.els.frame);
315
+ }
316
+ if (!this.engine) void this.load();
317
+ }
318
+ disconnectedCallback() {
319
+ this.resizeObserver?.disconnect();
320
+ this.resizeObserver = void 0;
321
+ this.engine?.destroy();
322
+ this.engine = void 0;
323
+ }
324
+ attributeChangedCallback(name, previous, next) {
325
+ if (previous === next || !this.isConnected) return;
326
+ if (name === "src" || name === "document" || name === "assets-base") void this.load();
327
+ else if (name === "chapters") void this.loadChapterAttribute();
328
+ else if (name === "muted") this.engine?.setMuted(next !== null);
329
+ }
330
+ // ------------------------------------------------------------------ loading
331
+ async load() {
332
+ const token = ++this.loadToken;
333
+ this.engine?.destroy();
334
+ this.engine = void 0;
335
+ this.removeAttribute("data-ready");
336
+ this.showError(null);
337
+ this.els.stage.replaceChildren();
338
+ const loop = this.hasAttribute("loop");
339
+ const muted = this.hasAttribute("muted");
340
+ try {
341
+ let engine;
342
+ let doc = this.docValue;
343
+ const docUrl = this.getAttribute("document");
344
+ if (!doc && docUrl) {
345
+ const response = await fetch(docUrl);
346
+ if (!response.ok) throw new Error(`could not load ${docUrl} (HTTP ${response.status})`);
347
+ doc = await response.json();
348
+ if (token !== this.loadToken) return;
349
+ }
350
+ if (doc) {
351
+ const base = this.getAttribute("assets-base") ?? (docUrl ? new URL(docUrl, location.href).href : void 0);
352
+ const resolved = resolveAssetSources(doc, base);
353
+ this.style.setProperty("--mc-aspect", `${resolved.settings.width}/${resolved.settings.height}`);
354
+ engine = await createDocumentEngine(resolved, { loop, muted, loadPlugins: this.hasAttribute("plugins") });
355
+ this.docChapters = (durationS) => chaptersFromMarkers(resolved.markers, durationS);
356
+ } else {
357
+ const src = this.getAttribute("src");
358
+ if (!src) return;
359
+ const poster = this.getAttribute("poster");
360
+ engine = createVideoEngine(src, { loop, muted, ...poster ? { poster } : {} });
361
+ this.docChapters = void 0;
362
+ const video = engine.element;
363
+ video.addEventListener("loadedmetadata", () => {
364
+ if (video.videoWidth && video.videoHeight) this.style.setProperty("--mc-aspect", `${video.videoWidth}/${video.videoHeight}`);
365
+ this.refreshChapters();
366
+ });
367
+ }
368
+ if (token !== this.loadToken) {
369
+ engine.destroy();
370
+ return;
371
+ }
372
+ this.engine = engine;
373
+ this.els.stage.append(engine.element);
374
+ let wasPlaying = false;
375
+ engine.onChange(() => {
376
+ const playing = engine.state().playing;
377
+ if (playing !== wasPlaying) {
378
+ wasPlaying = playing;
379
+ this.toggleAttribute("data-playing", playing);
380
+ this.dispatch(playing ? "play" : "pause");
381
+ }
382
+ this.render();
383
+ this.dispatch("timeupdate");
384
+ });
385
+ engine.onEnded(() => this.dispatch("ended"));
386
+ engine.onError((message) => {
387
+ this.showError(message);
388
+ this.dispatch("error", { message });
389
+ });
390
+ this.sizeEngine();
391
+ await this.loadChapterAttribute();
392
+ this.refreshChapters();
393
+ this.setAttribute("data-ready", "");
394
+ this.render();
395
+ this.dispatch("ready");
396
+ if (this.hasAttribute("autoplay")) engine.play();
397
+ } catch (error) {
398
+ if (token !== this.loadToken) return;
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ this.showError(message);
401
+ this.dispatch("error", { message });
402
+ }
403
+ }
404
+ docChapters;
405
+ attributeChapters;
406
+ async loadChapterAttribute() {
407
+ const url = this.getAttribute("chapters");
408
+ if (!url) {
409
+ this.attributeChapters = void 0;
410
+ return;
411
+ }
412
+ try {
413
+ const response = await fetch(url);
414
+ const text = await response.text();
415
+ this.attributeChapters = text.trimStart().startsWith("WEBVTT") ? parseVttChapters(text) : normalizeChapters(JSON.parse(text), this.duration);
416
+ } catch {
417
+ this.attributeChapters = void 0;
418
+ }
419
+ this.refreshChapters();
420
+ }
421
+ refreshChapters() {
422
+ const duration = this.duration;
423
+ this.chapterList = this.explicitChapters ?? this.attributeChapters ?? this.docChapters?.(duration) ?? [];
424
+ const { ticks, menu, chaptersBtn } = this.els;
425
+ ticks.replaceChildren();
426
+ menu.replaceChildren();
427
+ chaptersBtn.hidden = this.chapterList.length === 0;
428
+ for (const [i, chapter] of this.chapterList.entries()) {
429
+ if (duration > 0 && chapter.start > 0) {
430
+ const tick = document.createElement("div");
431
+ tick.className = "tick";
432
+ tick.style.left = `${chapter.start / duration * 100}%`;
433
+ tick.title = chapter.label;
434
+ ticks.append(tick);
435
+ }
436
+ const li = document.createElement("li");
437
+ const button = document.createElement("button");
438
+ button.type = "button";
439
+ button.dataset["index"] = String(i);
440
+ button.innerHTML = `<span class="t"></span><span class="n"></span>`;
441
+ button.querySelector(".t").textContent = formatTime(chapter.start);
442
+ button.querySelector(".n").textContent = chapter.label;
443
+ li.append(button);
444
+ menu.append(li);
445
+ }
446
+ this.currentChapter = void 0;
447
+ this.render();
448
+ }
449
+ // ------------------------------------------------------------------ UI
450
+ wire() {
451
+ const { big, play, scrub, chaptersBtn, menu, mute, frame } = this.els;
452
+ big.addEventListener("click", () => this.play());
453
+ play.addEventListener("click", () => this.toggle());
454
+ this.els.stage.addEventListener("click", () => this.toggle());
455
+ mute.addEventListener("click", () => {
456
+ const muted = !this.hasAttribute("muted");
457
+ this.toggleAttribute("muted", muted);
458
+ this.engine?.setMuted(muted);
459
+ mute.innerHTML = muted ? ICON.muted : ICON.sound;
460
+ mute.setAttribute("aria-label", muted ? "Unmute" : "Mute");
461
+ });
462
+ chaptersBtn.addEventListener("click", () => {
463
+ const open = menu.hidden;
464
+ menu.hidden = !open;
465
+ chaptersBtn.setAttribute("aria-expanded", String(open));
466
+ if (open) (menu.querySelector('button[aria-current="true"]') ?? menu.querySelector("button"))?.focus();
467
+ });
468
+ menu.addEventListener("click", (event) => {
469
+ const button = event.target.closest("button");
470
+ if (!button) return;
471
+ this.goToChapter(Number(button.dataset["index"]));
472
+ menu.hidden = true;
473
+ chaptersBtn.setAttribute("aria-expanded", "false");
474
+ });
475
+ const seekFromPointer = (event) => {
476
+ const rect = scrub.getBoundingClientRect();
477
+ if (rect.width <= 0) return;
478
+ this.seek((event.clientX - rect.left) / rect.width * this.duration);
479
+ };
480
+ scrub.addEventListener("pointerdown", (event) => {
481
+ this.dragging = true;
482
+ scrub.setPointerCapture?.(event.pointerId);
483
+ seekFromPointer(event);
484
+ });
485
+ scrub.addEventListener("pointermove", (event) => {
486
+ if (this.dragging) seekFromPointer(event);
487
+ });
488
+ scrub.addEventListener("pointerup", () => {
489
+ this.dragging = false;
490
+ });
491
+ const keys = (event) => {
492
+ const step = event.shiftKey ? 10 : 5;
493
+ if (event.key === " " || event.key === "k") this.toggle();
494
+ else if (event.key === "ArrowRight" || event.key === "l") this.seek(this.currentTime + step);
495
+ else if (event.key === "ArrowLeft" || event.key === "j") this.seek(this.currentTime - step);
496
+ else if (event.key === "Home") this.seek(0);
497
+ else if (event.key === "End") this.seek(this.duration);
498
+ else if (event.key === "]") this.stepChapter(1);
499
+ else if (event.key === "[") this.stepChapter(-1);
500
+ else if (event.key === "Escape") menu.hidden = true;
501
+ else return;
502
+ event.preventDefault();
503
+ };
504
+ frame.addEventListener("keydown", keys);
505
+ }
506
+ stepChapter(direction) {
507
+ if (this.chapterList.length === 0) return;
508
+ const t = this.currentTime;
509
+ if (direction > 0) {
510
+ const next = this.chapterList.find((c) => c.start > t + 0.25);
511
+ if (next) this.seek(next.start);
512
+ } else {
513
+ const previous = [...this.chapterList].reverse().find((c) => c.start < t - 1);
514
+ this.seek(previous?.start ?? 0);
515
+ }
516
+ }
517
+ render() {
518
+ const state = this.engine?.state() ?? { playing: false, timeS: 0, durationS: 0 };
519
+ const { time, fill, thumb, scrub, play, label, menu } = this.els;
520
+ const fraction = state.durationS > 0 ? Math.min(1, state.timeS / state.durationS) : 0;
521
+ time.textContent = `${formatTime(state.timeS)} / ${formatTime(state.durationS)}`;
522
+ fill.style.width = `${fraction * 100}%`;
523
+ thumb.style.left = `${fraction * 100}%`;
524
+ scrub.setAttribute("aria-valuemax", String(Math.round(state.durationS)));
525
+ scrub.setAttribute("aria-valuenow", String(Math.round(state.timeS)));
526
+ scrub.setAttribute("aria-valuetext", time.textContent);
527
+ const wanted = state.playing ? "Pause" : "Play";
528
+ if (play.getAttribute("aria-label") !== wanted) {
529
+ play.innerHTML = state.playing ? ICON.pause : ICON.play;
530
+ play.setAttribute("aria-label", wanted);
531
+ }
532
+ const chapter = chapterAt(this.chapterList, state.timeS);
533
+ label.textContent = chapter?.label ?? "";
534
+ if (chapter?.id !== this.currentChapter?.id) {
535
+ this.currentChapter = chapter;
536
+ for (const button of Array.from(menu.querySelectorAll("button"))) {
537
+ button.setAttribute("aria-current", String(this.chapterList[Number(button.dataset["index"])]?.id === chapter?.id));
538
+ }
539
+ this.dispatch("chapterchange", { chapter: chapter ?? null });
540
+ }
541
+ }
542
+ sizeEngine() {
543
+ const rect = this.els.frame.getBoundingClientRect();
544
+ this.engine?.resize(rect.width, rect.height);
545
+ }
546
+ showError(message) {
547
+ this.els.error.hidden = message === null;
548
+ this.els.error.textContent = message ?? "";
549
+ }
550
+ dispatch(type, detail) {
551
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: type !== "timeupdate", composed: true }));
552
+ }
553
+ };
554
+ function defineMiraiclipPlayer(tagName = "miraiclip-player") {
555
+ if (typeof customElements === "undefined") return MiraiclipPlayerElement;
556
+ const existing = customElements.get(tagName);
557
+ if (existing) return existing;
558
+ customElements.define(tagName, MiraiclipPlayerElement);
559
+ return MiraiclipPlayerElement;
560
+ }
561
+
562
+ export {
563
+ chaptersFromMarkers,
564
+ parseVttChapters,
565
+ normalizeChapters,
566
+ chapterAt,
567
+ formatTime,
568
+ resolveAssetSources,
569
+ MiraiclipPlayerElement,
570
+ defineMiraiclipPlayer
571
+ };
572
+ //# sourceMappingURL=chunk-TMHYBF54.js.map