@sweet-player/core 0.2.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/dist/index.js ADDED
@@ -0,0 +1,1513 @@
1
+ // src/core/events.ts
2
+ var EventEmitter = class {
3
+ constructor() {
4
+ this.listeners = /* @__PURE__ */ new Map();
5
+ }
6
+ on(event, fn) {
7
+ let set = this.listeners.get(event);
8
+ if (!set) {
9
+ set = /* @__PURE__ */ new Set();
10
+ this.listeners.set(event, set);
11
+ }
12
+ set.add(fn);
13
+ return () => this.off(event, fn);
14
+ }
15
+ off(event, fn) {
16
+ this.listeners.get(event)?.delete(fn);
17
+ }
18
+ emit(event, payload) {
19
+ this.listeners.get(event)?.forEach((fn) => fn(payload));
20
+ }
21
+ removeAll() {
22
+ this.listeners.clear();
23
+ }
24
+ };
25
+
26
+ // src/core/media.ts
27
+ import Hls from "hls.js";
28
+ function isHlsSource(src) {
29
+ return /\.m3u8($|\?)/i.test(src);
30
+ }
31
+ var MediaController = class {
32
+ constructor(video, emitter, hlsConfig, callbacks = {}) {
33
+ this.video = video;
34
+ this.emitter = emitter;
35
+ this.hlsConfig = hlsConfig;
36
+ this.callbacks = callbacks;
37
+ this.hls = null;
38
+ /** 最近一次加载的地址,错误重试用 */
39
+ this.currentSrc = "";
40
+ }
41
+ load(src) {
42
+ this.detachHls();
43
+ this.currentSrc = src;
44
+ if (isHlsSource(src)) {
45
+ if (Hls.isSupported()) {
46
+ this.hls = new Hls(this.hlsConfig);
47
+ this.hls.on(Hls.Events.ERROR, (_event, data) => {
48
+ if (data.fatal) {
49
+ switch (data.type) {
50
+ case Hls.ErrorTypes.NETWORK_ERROR:
51
+ this.emitter.emit("error", { type: "hls-network", detail: data });
52
+ break;
53
+ case Hls.ErrorTypes.MEDIA_ERROR:
54
+ this.hls?.recoverMediaError();
55
+ break;
56
+ default:
57
+ this.emitter.emit("error", { type: "hls-fatal", detail: data });
58
+ this.detachHls();
59
+ }
60
+ }
61
+ });
62
+ this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
63
+ this.notifyTracks();
64
+ });
65
+ this.hls.loadSource(src);
66
+ this.hls.attachMedia(this.video);
67
+ return;
68
+ }
69
+ if (this.video.canPlayType("application/vnd.apple.mpegurl")) {
70
+ this.video.src = src;
71
+ return;
72
+ }
73
+ this.emitter.emit("error", { type: "hls-unsupported" });
74
+ return;
75
+ }
76
+ this.video.src = src;
77
+ }
78
+ /** 重新加载当前地址(错误重试) */
79
+ reload() {
80
+ if (this.currentSrc) this.load(this.currentSrc);
81
+ }
82
+ notifyTracks() {
83
+ if (!this.hls) return;
84
+ if (this.hls.levels.length > 1 && this.callbacks.onLevels) {
85
+ const levels = this.hls.levels.map((l, i) => ({
86
+ index: i,
87
+ height: l.height,
88
+ bitrate: l.bitrate,
89
+ label: l.height ? `${l.height}P` : `${Math.round(l.bitrate / 1e3)}kbps`
90
+ })).sort((a, b) => b.height - a.height);
91
+ this.callbacks.onLevels(levels);
92
+ }
93
+ if (this.hls.audioTracks.length > 1 && this.callbacks.onAudioTracks) {
94
+ this.callbacks.onAudioTracks(
95
+ this.hls.audioTracks.map((t, i) => ({ index: i, label: t.name || t.lang || `Track ${i + 1}` }))
96
+ );
97
+ }
98
+ }
99
+ /** 切换 hls 画质档位,-1 为自动 */
100
+ setLevel(index) {
101
+ if (this.hls) this.hls.currentLevel = index;
102
+ }
103
+ setAudioTrack(index) {
104
+ if (this.hls) this.hls.audioTrack = index;
105
+ }
106
+ /** hls.js 带宽估算(bps),统计面板用 */
107
+ get bandwidthEstimate() {
108
+ return this.hls?.bandwidthEstimate;
109
+ }
110
+ /** 当前 hls level 描述,统计面板用 */
111
+ get currentLevelInfo() {
112
+ if (!this.hls || this.hls.currentLevel < 0) return void 0;
113
+ const level = this.hls.levels[this.hls.currentLevel];
114
+ if (!level) return void 0;
115
+ return `${level.width}x${level.height}@${Math.round(level.bitrate / 1e3)}kbps`;
116
+ }
117
+ detachHls() {
118
+ if (this.hls) {
119
+ this.hls.destroy();
120
+ this.hls = null;
121
+ }
122
+ }
123
+ destroy() {
124
+ this.detachHls();
125
+ this.video.removeAttribute("src");
126
+ this.video.load();
127
+ }
128
+ };
129
+
130
+ // src/core/keyboard.ts
131
+ var HOLD_THRESHOLD = 300;
132
+ var TICK_INTERVAL = 250;
133
+ function isEditableTarget(target) {
134
+ if (!(target instanceof HTMLElement)) return false;
135
+ return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.isContentEditable;
136
+ }
137
+ var KeyboardController = class {
138
+ constructor(container, actions, options) {
139
+ this.container = container;
140
+ this.actions = actions;
141
+ this.options = options;
142
+ this.holdDirection = 0;
143
+ this.holdStart = 0;
144
+ this.accumulated = 0;
145
+ this.tickTimer = null;
146
+ this.hovering = false;
147
+ this.onKeyDown = (e) => this.handleKeyDown(e);
148
+ this.onKeyUp = (e) => this.handleKeyUp(e);
149
+ this.onEnter = () => this.hovering = true;
150
+ this.onLeave = () => this.hovering = false;
151
+ document.addEventListener("keydown", this.onKeyDown);
152
+ document.addEventListener("keyup", this.onKeyUp);
153
+ container.addEventListener("mouseenter", this.onEnter);
154
+ container.addEventListener("mouseleave", this.onLeave);
155
+ }
156
+ isScoped(e) {
157
+ if (isEditableTarget(e.target)) return false;
158
+ return this.hovering || this.container.contains(document.activeElement);
159
+ }
160
+ handleKeyDown(e) {
161
+ if (!this.isScoped(e)) return;
162
+ switch (e.key) {
163
+ case " ":
164
+ e.preventDefault();
165
+ if (!e.repeat) this.actions.togglePlay();
166
+ break;
167
+ case "ArrowLeft":
168
+ case "ArrowRight": {
169
+ e.preventDefault();
170
+ if (e.repeat) return;
171
+ this.startHold(e.key === "ArrowRight" ? 1 : -1);
172
+ break;
173
+ }
174
+ case "ArrowUp":
175
+ e.preventDefault();
176
+ this.actions.adjustVolume(5);
177
+ break;
178
+ case "ArrowDown":
179
+ e.preventDefault();
180
+ this.actions.adjustVolume(-5);
181
+ break;
182
+ case "f":
183
+ case "F":
184
+ if (!e.repeat) this.actions.toggleFullscreen();
185
+ break;
186
+ case "m":
187
+ case "M":
188
+ if (!e.repeat) this.actions.toggleMute();
189
+ break;
190
+ }
191
+ }
192
+ handleKeyUp(e) {
193
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
194
+ const dir = e.key === "ArrowRight" ? 1 : -1;
195
+ if (this.holdDirection !== dir) return;
196
+ this.endHold();
197
+ }
198
+ startHold(direction) {
199
+ if (this.holdDirection !== 0) this.endHold();
200
+ this.holdDirection = direction;
201
+ this.holdStart = performance.now();
202
+ this.accumulated = 0;
203
+ this.tickTimer = setInterval(() => {
204
+ const heldMs = performance.now() - this.holdStart;
205
+ if (heldMs < HOLD_THRESHOLD) return;
206
+ const { longSeekSteps, stepUpInterval } = this.options;
207
+ const levelIndex = Math.min(
208
+ Math.floor((heldMs - HOLD_THRESHOLD) / stepUpInterval),
209
+ longSeekSteps.length - 1
210
+ );
211
+ const stepPerSecond = longSeekSteps[levelIndex];
212
+ this.accumulated += stepPerSecond * (TICK_INTERVAL / 1e3) * this.holdDirection;
213
+ this.actions.onLongSeekProgress(Math.round(this.accumulated));
214
+ }, TICK_INTERVAL);
215
+ }
216
+ endHold() {
217
+ if (this.tickTimer) {
218
+ clearInterval(this.tickTimer);
219
+ this.tickTimer = null;
220
+ }
221
+ const heldMs = performance.now() - this.holdStart;
222
+ const dir = this.holdDirection;
223
+ this.holdDirection = 0;
224
+ if (heldMs < HOLD_THRESHOLD) {
225
+ this.actions.seekBy(this.options.seekStep * dir);
226
+ } else {
227
+ this.actions.onLongSeekCommit(Math.round(this.accumulated));
228
+ }
229
+ this.accumulated = 0;
230
+ }
231
+ destroy() {
232
+ if (this.tickTimer) clearInterval(this.tickTimer);
233
+ document.removeEventListener("keydown", this.onKeyDown);
234
+ document.removeEventListener("keyup", this.onKeyUp);
235
+ this.container.removeEventListener("mouseenter", this.onEnter);
236
+ this.container.removeEventListener("mouseleave", this.onLeave);
237
+ }
238
+ };
239
+
240
+ // src/core/gestures.ts
241
+ var SWIPE_THRESHOLD = 12;
242
+ var DOUBLE_TAP_WINDOW = 280;
243
+ var SEEK_PER_WIDTH = 120;
244
+ var VOLUME_PER_HEIGHT = 100;
245
+ var GestureController = class {
246
+ constructor(container, video, actions, seekStep) {
247
+ this.container = container;
248
+ this.video = video;
249
+ this.actions = actions;
250
+ this.seekStep = seekStep;
251
+ this.startX = 0;
252
+ this.startY = 0;
253
+ this.mode = "none";
254
+ this.pendingDelta = 0;
255
+ this.lastTapTime = 0;
256
+ this.lastTapX = 0;
257
+ this.singleTapTimer = null;
258
+ this.onPointerDown = (e) => this.handleDown(e);
259
+ this.onPointerMove = (e) => this.handleMove(e);
260
+ this.onPointerUp = (e) => this.handleUp(e);
261
+ container.addEventListener("pointerdown", this.onPointerDown);
262
+ container.addEventListener("pointermove", this.onPointerMove);
263
+ container.addEventListener("pointerup", this.onPointerUp);
264
+ container.addEventListener("pointercancel", this.onPointerUp);
265
+ }
266
+ /** 手势只在视频画面区域生效,控件上的触摸不拦截 */
267
+ isOnVideoSurface(e) {
268
+ return e.target === this.video || e.target === this.container;
269
+ }
270
+ handleDown(e) {
271
+ if (e.pointerType !== "touch" || !this.isOnVideoSurface(e)) return;
272
+ this.startX = e.clientX;
273
+ this.startY = e.clientY;
274
+ this.mode = "none";
275
+ this.pendingDelta = 0;
276
+ }
277
+ handleMove(e) {
278
+ if (e.pointerType !== "touch" || this.mode === "none" && !this.isOnVideoSurface(e)) return;
279
+ const dx = e.clientX - this.startX;
280
+ const dy = e.clientY - this.startY;
281
+ const rect = this.container.getBoundingClientRect();
282
+ if (this.mode === "none") {
283
+ if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
284
+ this.mode = "seek";
285
+ } else if (Math.abs(dy) > SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
286
+ if (this.startX - rect.left > rect.width / 2) this.mode = "volume";
287
+ }
288
+ if (this.mode !== "none") this.container.setPointerCapture(e.pointerId);
289
+ }
290
+ if (this.mode === "seek") {
291
+ this.pendingDelta = Math.round(dx / rect.width * SEEK_PER_WIDTH);
292
+ this.actions.onSeekPreview(this.pendingDelta);
293
+ } else if (this.mode === "volume") {
294
+ const deltaVol = Math.round(-(e.clientY - this.startY) / rect.height * VOLUME_PER_HEIGHT);
295
+ if (deltaVol !== 0) {
296
+ this.actions.adjustVolume(deltaVol);
297
+ this.startY = e.clientY;
298
+ }
299
+ }
300
+ }
301
+ handleUp(e) {
302
+ if (e.pointerType !== "touch") return;
303
+ if (this.mode === "seek") {
304
+ this.actions.onSeekCommit(this.pendingDelta);
305
+ } else if (this.mode === "none" && this.isOnVideoSurface(e)) {
306
+ this.handleTap(e);
307
+ }
308
+ this.mode = "none";
309
+ this.pendingDelta = 0;
310
+ }
311
+ handleTap(e) {
312
+ const now = Date.now();
313
+ const rect = this.container.getBoundingClientRect();
314
+ if (now - this.lastTapTime < DOUBLE_TAP_WINDOW && Math.abs(e.clientX - this.lastTapX) < 60) {
315
+ if (this.singleTapTimer) {
316
+ clearTimeout(this.singleTapTimer);
317
+ this.singleTapTimer = null;
318
+ }
319
+ this.lastTapTime = 0;
320
+ const zone = (e.clientX - rect.left) / rect.width;
321
+ if (zone < 1 / 3) this.actions.seekBy(-this.seekStep);
322
+ else if (zone > 2 / 3) this.actions.seekBy(this.seekStep);
323
+ else this.actions.toggleFullscreen();
324
+ return;
325
+ }
326
+ this.lastTapTime = now;
327
+ this.lastTapX = e.clientX;
328
+ this.singleTapTimer = setTimeout(() => {
329
+ this.singleTapTimer = null;
330
+ this.actions.toggleControls();
331
+ }, DOUBLE_TAP_WINDOW);
332
+ }
333
+ destroy() {
334
+ if (this.singleTapTimer) clearTimeout(this.singleTapTimer);
335
+ this.container.removeEventListener("pointerdown", this.onPointerDown);
336
+ this.container.removeEventListener("pointermove", this.onPointerMove);
337
+ this.container.removeEventListener("pointerup", this.onPointerUp);
338
+ this.container.removeEventListener("pointercancel", this.onPointerUp);
339
+ }
340
+ };
341
+
342
+ // src/core/fullscreen.ts
343
+ function isFullscreen(el) {
344
+ const doc = document;
345
+ const current = doc.fullscreenElement ?? doc.webkitFullscreenElement;
346
+ return current === el;
347
+ }
348
+ async function toggleFullscreen(el) {
349
+ const doc = document;
350
+ const fsEl = el;
351
+ if (isFullscreen(el)) {
352
+ await (doc.exitFullscreen?.() ?? doc.webkitExitFullscreen?.());
353
+ } else {
354
+ await (fsEl.requestFullscreen?.() ?? fsEl.webkitRequestFullscreen?.());
355
+ }
356
+ }
357
+ function onFullscreenChange(el, cb) {
358
+ const handler = () => cb(isFullscreen(el));
359
+ document.addEventListener("fullscreenchange", handler);
360
+ document.addEventListener("webkitfullscreenchange", handler);
361
+ return () => {
362
+ document.removeEventListener("fullscreenchange", handler);
363
+ document.removeEventListener("webkitfullscreenchange", handler);
364
+ };
365
+ }
366
+
367
+ // src/utils/dom.ts
368
+ function createEl(tag, { className, text, html, attrs, parent } = {}) {
369
+ const el = document.createElement(tag);
370
+ if (className) el.className = className;
371
+ if (text) el.textContent = text;
372
+ if (html) el.innerHTML = html;
373
+ if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
374
+ if (parent) parent.appendChild(el);
375
+ return el;
376
+ }
377
+ function createSvgIcon(pathData, viewBox = "0 0 24 24") {
378
+ return `<svg viewBox="${viewBox}" fill="currentColor" aria-hidden="true"><path d="${pathData}"/></svg>`;
379
+ }
380
+
381
+ // src/utils/time.ts
382
+ function formatTime(seconds) {
383
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
384
+ const s = Math.floor(seconds % 60);
385
+ const m = Math.floor(seconds / 60 % 60);
386
+ const h = Math.floor(seconds / 3600);
387
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
388
+ const ss = String(s).padStart(2, "0");
389
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
390
+ }
391
+ function clamp(value, min, max) {
392
+ return Math.min(max, Math.max(min, value));
393
+ }
394
+
395
+ // src/ui/icons.ts
396
+ var icons = {
397
+ play: createSvgIcon("M8 5v14l11-7z"),
398
+ pause: createSvgIcon("M6 19h4V5H6v14zm8-14v14h4V5h-4z"),
399
+ prev: createSvgIcon("M6 6h2v12H6zm3.5 6l8.5 6V6z"),
400
+ next: createSvgIcon("M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"),
401
+ seekBack: createSvgIcon(
402
+ "M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"
403
+ ),
404
+ seekForward: createSvgIcon(
405
+ "M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"
406
+ ),
407
+ volumeHigh: createSvgIcon(
408
+ "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
409
+ ),
410
+ volumeLow: createSvgIcon(
411
+ "M5 9v6h4l5 5V4L9 9H5zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
412
+ ),
413
+ volumeMute: createSvgIcon(
414
+ "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
415
+ ),
416
+ fullscreen: createSvgIcon(
417
+ "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
418
+ ),
419
+ fullscreenExit: createSvgIcon(
420
+ "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
421
+ ),
422
+ pip: createSvgIcon(
423
+ "M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z"
424
+ ),
425
+ audio: createSvgIcon(
426
+ "M12 3a9 9 0 0 0-9 9v7c0 1.1.9 2 2 2h4v-8H5v-1c0-3.87 3.13-7 7-7s7 3.13 7 7v1h-4v8h4c1.1 0 2-.9 2-2v-7a9 9 0 0 0-9-9z"
427
+ )
428
+ };
429
+
430
+ // src/ui/components/menu.ts
431
+ function createPopupMenu(opts) {
432
+ const root = createEl("div", { className: "sp-menu" });
433
+ const btn = createEl("button", {
434
+ className: "sp-btn sp-menu-btn",
435
+ html: opts.buttonHtml,
436
+ attrs: { type: "button", title: opts.title, "aria-label": opts.title },
437
+ parent: root
438
+ });
439
+ const panel = createEl("div", { className: "sp-menu-panel", parent: root });
440
+ let items = [];
441
+ let activeValue;
442
+ function render() {
443
+ panel.innerHTML = "";
444
+ if (items.length === 0) {
445
+ createEl("div", { className: "sp-menu-empty", text: opts.emptyText ?? "-", parent: panel });
446
+ return;
447
+ }
448
+ for (const item of items) {
449
+ const itemBtn = createEl("button", {
450
+ className: "sp-menu-item" + (item.value === activeValue ? " sp-active" : ""),
451
+ text: item.label,
452
+ attrs: { type: "button" },
453
+ parent: panel
454
+ });
455
+ itemBtn.addEventListener("click", (e) => {
456
+ e.stopPropagation();
457
+ close();
458
+ opts.onSelect(item);
459
+ });
460
+ }
461
+ }
462
+ function close() {
463
+ root.classList.remove("sp-open");
464
+ }
465
+ btn.addEventListener("click", (e) => {
466
+ e.stopPropagation();
467
+ const willOpen = !root.classList.contains("sp-open");
468
+ root.closest(".sp-controls")?.querySelectorAll(".sp-menu.sp-open").forEach((m) => m.classList.remove("sp-open"));
469
+ root.classList.toggle("sp-open", willOpen);
470
+ });
471
+ document.addEventListener("click", close);
472
+ render();
473
+ return {
474
+ el: root,
475
+ setItems(next) {
476
+ items = next;
477
+ render();
478
+ },
479
+ setActive(value) {
480
+ activeValue = value;
481
+ render();
482
+ },
483
+ setButtonContent(html) {
484
+ btn.innerHTML = html;
485
+ },
486
+ close
487
+ };
488
+ }
489
+
490
+ // src/ui/components/progressBar.ts
491
+ function createProgressBar(video, onSeek) {
492
+ const root = createEl("div", { className: "sp-progress" });
493
+ const track = createEl("div", { className: "sp-progress-track", parent: root });
494
+ const buffered = createEl("div", { className: "sp-progress-buffered", parent: track });
495
+ const played = createEl("div", { className: "sp-progress-played", parent: track });
496
+ const thumb = createEl("div", { className: "sp-progress-thumb", parent: track });
497
+ const tooltip = createEl("div", { className: "sp-progress-tooltip", text: "0:00", parent: root });
498
+ let dragging = false;
499
+ function ratioFromEvent(e) {
500
+ const rect = root.getBoundingClientRect();
501
+ return clamp((e.clientX - rect.left) / rect.width, 0, 1);
502
+ }
503
+ function render(ratio) {
504
+ const duration = video.duration || 0;
505
+ const playedRatio = ratio ?? (duration ? video.currentTime / duration : 0);
506
+ played.style.width = `${playedRatio * 100}%`;
507
+ thumb.style.left = `${playedRatio * 100}%`;
508
+ let bufferedEnd = 0;
509
+ for (let i = 0; i < video.buffered.length; i++) {
510
+ if (video.buffered.start(i) <= video.currentTime && video.buffered.end(i) > bufferedEnd) {
511
+ bufferedEnd = video.buffered.end(i);
512
+ }
513
+ }
514
+ buffered.style.width = duration ? `${bufferedEnd / duration * 100}%` : "0%";
515
+ }
516
+ function onPointerMove(e) {
517
+ const ratio = ratioFromEvent(e);
518
+ tooltip.style.left = `${ratio * 100}%`;
519
+ tooltip.textContent = formatTime(ratio * (video.duration || 0));
520
+ if (dragging) render(ratio);
521
+ }
522
+ function onPointerDown(e) {
523
+ dragging = true;
524
+ root.classList.add("sp-dragging");
525
+ root.setPointerCapture(e.pointerId);
526
+ render(ratioFromEvent(e));
527
+ }
528
+ function onPointerUp(e) {
529
+ if (!dragging) return;
530
+ dragging = false;
531
+ root.classList.remove("sp-dragging");
532
+ onSeek(ratioFromEvent(e) * (video.duration || 0));
533
+ }
534
+ root.addEventListener("pointermove", onPointerMove);
535
+ root.addEventListener("pointerdown", onPointerDown);
536
+ root.addEventListener("pointerup", onPointerUp);
537
+ root.addEventListener("pointercancel", () => {
538
+ dragging = false;
539
+ root.classList.remove("sp-dragging");
540
+ });
541
+ return {
542
+ el: root,
543
+ update() {
544
+ if (!dragging) render();
545
+ },
546
+ destroy() {
547
+ root.remove();
548
+ }
549
+ };
550
+ }
551
+
552
+ // src/ui/components/volume.ts
553
+ function createVolumeControl(opts) {
554
+ const root = createEl("div", { className: "sp-volume" });
555
+ const btn = createEl("button", {
556
+ className: "sp-btn",
557
+ html: icons.volumeHigh,
558
+ attrs: { type: "button", title: opts.muteTitle, "aria-label": opts.muteTitle },
559
+ parent: root
560
+ });
561
+ const sliderWrap = createEl("div", { className: "sp-volume-slider", parent: root });
562
+ const track = createEl("div", { className: "sp-volume-track", parent: sliderWrap });
563
+ const fill = createEl("div", { className: "sp-volume-fill", parent: track });
564
+ const thumb = createEl("div", { className: "sp-volume-thumb", parent: track });
565
+ btn.addEventListener("click", opts.onToggleMute);
566
+ let dragging = false;
567
+ function volumeFromEvent(e) {
568
+ const rect = track.getBoundingClientRect();
569
+ return Math.round(clamp((e.clientX - rect.left) / rect.width, 0, 1) * 100);
570
+ }
571
+ track.addEventListener("pointerdown", (e) => {
572
+ dragging = true;
573
+ root.classList.add("sp-dragging");
574
+ track.setPointerCapture(e.pointerId);
575
+ opts.onVolumeChange(volumeFromEvent(e));
576
+ });
577
+ track.addEventListener("pointermove", (e) => {
578
+ if (dragging) opts.onVolumeChange(volumeFromEvent(e));
579
+ });
580
+ const endDrag = () => {
581
+ dragging = false;
582
+ root.classList.remove("sp-dragging");
583
+ };
584
+ track.addEventListener("pointerup", endDrag);
585
+ track.addEventListener("pointercancel", endDrag);
586
+ return {
587
+ el: root,
588
+ update(volume, muted) {
589
+ const shown = muted ? 0 : volume;
590
+ fill.style.width = `${shown}%`;
591
+ thumb.style.left = `${shown}%`;
592
+ btn.innerHTML = muted || volume === 0 ? icons.volumeMute : volume < 50 ? icons.volumeLow : icons.volumeHigh;
593
+ }
594
+ };
595
+ }
596
+
597
+ // src/ui/controls.ts
598
+ function createControls(ctx) {
599
+ const { video, actions, i18n } = ctx;
600
+ const topEl = createEl("div", { className: "sp-top" });
601
+ const titleEl = createEl("div", { className: "sp-title", text: ctx.title, parent: topEl });
602
+ titleEl.addEventListener("click", actions.onTitleClick);
603
+ const bottomEl = createEl("div", { className: "sp-bottom" });
604
+ const progress = createProgressBar(video, (t) => {
605
+ video.currentTime = t;
606
+ });
607
+ bottomEl.appendChild(progress.el);
608
+ const row = createEl("div", { className: "sp-controls", parent: bottomEl });
609
+ const button = (html, title, onClick, disabled = false) => {
610
+ const btn = createEl("button", {
611
+ className: "sp-btn",
612
+ html,
613
+ attrs: { type: "button", title, "aria-label": title },
614
+ parent: row
615
+ });
616
+ btn.disabled = disabled;
617
+ btn.addEventListener("click", onClick);
618
+ return btn;
619
+ };
620
+ button(icons.prev, i18n.t("prev"), () => actions.onPrev?.(), !actions.onPrev);
621
+ button(icons.seekBack, i18n.t("seekBack", { n: ctx.seekStep }), () => actions.seekBy(-ctx.seekStep));
622
+ const playBtn = button(icons.play, i18n.t("playPause"), actions.togglePlay);
623
+ button(icons.seekForward, i18n.t("seekForward", { n: ctx.seekStep }), () => actions.seekBy(ctx.seekStep));
624
+ button(icons.next, i18n.t("next"), () => actions.onNext?.(), !actions.onNext);
625
+ const timeEl = createEl("span", { className: "sp-time", text: "0:00 / 0:00", parent: row });
626
+ createEl("div", { className: "sp-controls-spacer", parent: row });
627
+ const rateMenu = createPopupMenu({
628
+ buttonHtml: "1x",
629
+ title: i18n.t("speed"),
630
+ emptyText: i18n.t("empty"),
631
+ onSelect: (item) => actions.setRate(item.value)
632
+ });
633
+ rateMenu.setItems(ctx.playbackRates.map((r) => ({ label: `${r}x`, value: r })));
634
+ rateMenu.setActive(video.playbackRate);
635
+ row.appendChild(rateMenu.el);
636
+ const qualityMenu = createPopupMenu({
637
+ buttonHtml: i18n.t("quality"),
638
+ title: i18n.t("quality"),
639
+ emptyText: i18n.t("empty"),
640
+ onSelect: (item) => actions.selectQuality(item.value)
641
+ });
642
+ row.appendChild(qualityMenu.el);
643
+ const ratioMenu = createPopupMenu({
644
+ buttonHtml: i18n.t("aspectRatio"),
645
+ title: i18n.t("aspectRatio"),
646
+ emptyText: i18n.t("empty"),
647
+ onSelect: (item) => actions.setAspectRatio(item.value)
648
+ });
649
+ ratioMenu.setItems(
650
+ ctx.aspectRatios.map((r) => ({ label: r === "original" ? i18n.t("ratioOriginal") : r, value: r }))
651
+ );
652
+ ratioMenu.setActive("original");
653
+ row.appendChild(ratioMenu.el);
654
+ const audioMenu = createPopupMenu({
655
+ buttonHtml: icons.audio,
656
+ title: i18n.t("audioTrack"),
657
+ emptyText: i18n.t("empty"),
658
+ onSelect: (item) => actions.selectAudioTrack(item.value)
659
+ });
660
+ row.appendChild(audioMenu.el);
661
+ const volume = createVolumeControl({
662
+ muteTitle: i18n.t("mute"),
663
+ onVolumeChange: actions.setVolume,
664
+ onToggleMute: actions.toggleMute
665
+ });
666
+ row.appendChild(volume.el);
667
+ if (document.pictureInPictureEnabled) {
668
+ button(icons.pip, i18n.t("pip"), actions.togglePip);
669
+ }
670
+ const fsBtn = button(icons.fullscreen, i18n.t("fullscreen"), actions.toggleFullscreen);
671
+ return {
672
+ topEl,
673
+ bottomEl,
674
+ progress,
675
+ volume,
676
+ qualityMenu,
677
+ audioMenu,
678
+ updatePlayState(playing) {
679
+ playBtn.innerHTML = playing ? icons.pause : icons.play;
680
+ },
681
+ updateTime() {
682
+ timeEl.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
683
+ progress.update();
684
+ },
685
+ updateRate(rate) {
686
+ rateMenu.setButtonContent(`${rate}x`);
687
+ rateMenu.setActive(rate);
688
+ },
689
+ updateFullscreen(fs) {
690
+ fsBtn.innerHTML = fs ? icons.fullscreenExit : icons.fullscreen;
691
+ },
692
+ updateRatio(ratio) {
693
+ ratioMenu.setActive(ratio);
694
+ },
695
+ destroy() {
696
+ topEl.remove();
697
+ bottomEl.remove();
698
+ }
699
+ };
700
+ }
701
+
702
+ // src/ui/components/osd.ts
703
+ function createOsd() {
704
+ const el = createEl("div", { className: "sp-osd" });
705
+ let hideTimer = null;
706
+ function show(text) {
707
+ if (hideTimer) {
708
+ clearTimeout(hideTimer);
709
+ hideTimer = null;
710
+ }
711
+ el.textContent = text;
712
+ el.classList.add("sp-visible");
713
+ }
714
+ function hide() {
715
+ el.classList.remove("sp-visible");
716
+ }
717
+ return {
718
+ el,
719
+ show,
720
+ hide,
721
+ flash(text) {
722
+ show(text);
723
+ hideTimer = setTimeout(hide, 800);
724
+ }
725
+ };
726
+ }
727
+
728
+ // src/ui/components/statsOverlay.ts
729
+ function createStatsOverlay(container, video, media, i18n) {
730
+ let root = null;
731
+ let timer = null;
732
+ function collect() {
733
+ const q = video.getVideoPlaybackQuality?.();
734
+ const bufferedRanges = [];
735
+ for (let i = 0; i < video.buffered.length; i++) {
736
+ bufferedRanges.push(`${formatTime(video.buffered.start(i))}-${formatTime(video.buffered.end(i))}`);
737
+ }
738
+ const bw = media.bandwidthEstimate;
739
+ return [
740
+ ["Video Res", `${video.videoWidth}x${video.videoHeight}`],
741
+ ["Viewport", `${container.clientWidth}x${container.clientHeight}`],
742
+ ["Time", `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`],
743
+ ["Buffered", bufferedRanges.join(", ") || "-"],
744
+ ["Dropped Frames", q ? `${q.droppedVideoFrames} / ${q.totalVideoFrames}` : "N/A"],
745
+ ["Bandwidth", bw ? `${(bw / 1e3 / 1e3).toFixed(2)} Mbps` : "N/A"],
746
+ ["HLS Level", media.currentLevelInfo ?? "N/A"],
747
+ ["Speed", `${video.playbackRate}x`],
748
+ ["Volume", video.muted ? "muted" : `${Math.round(video.volume * 100)}%`],
749
+ ["Ready State", String(video.readyState)],
750
+ ["Source", video.currentSrc || "-"]
751
+ ];
752
+ }
753
+ function render() {
754
+ if (!root) return;
755
+ const dl = root.querySelector(".sp-stats-body");
756
+ dl.innerHTML = "";
757
+ for (const [label, value] of collect()) {
758
+ const row = createEl("div", { className: "sp-stats-row", parent: dl });
759
+ createEl("dt", { text: label, parent: row });
760
+ createEl("dd", { text: value, parent: row });
761
+ }
762
+ }
763
+ function show() {
764
+ if (root) return;
765
+ root = createEl("div", { className: "sp-stats", parent: container });
766
+ const closeBtn = createEl("button", {
767
+ className: "sp-stats-close",
768
+ text: "\u2715",
769
+ attrs: { type: "button", "aria-label": i18n.t("closeStats") },
770
+ parent: root
771
+ });
772
+ closeBtn.addEventListener("click", hide);
773
+ createEl("dl", { className: "sp-stats-body", parent: root });
774
+ render();
775
+ timer = setInterval(render, 500);
776
+ }
777
+ function hide() {
778
+ if (timer) {
779
+ clearInterval(timer);
780
+ timer = null;
781
+ }
782
+ root?.remove();
783
+ root = null;
784
+ }
785
+ return {
786
+ toggle() {
787
+ root ? hide() : show();
788
+ },
789
+ hide,
790
+ destroy: hide
791
+ };
792
+ }
793
+
794
+ // src/ui/components/stateOverlay.ts
795
+ function createStateOverlay(i18n) {
796
+ const el = createEl("div", { className: "sp-state" });
797
+ const spinner = createEl("div", { className: "sp-spinner", parent: el });
798
+ spinner.innerHTML = '<div class="sp-spinner-ring"></div>';
799
+ let errorEl = null;
800
+ let endedEl = null;
801
+ let countdownTimer = null;
802
+ function stopCountdown() {
803
+ if (countdownTimer) {
804
+ clearInterval(countdownTimer);
805
+ countdownTimer = null;
806
+ }
807
+ }
808
+ function hideError() {
809
+ errorEl?.remove();
810
+ errorEl = null;
811
+ }
812
+ function hideEnded() {
813
+ stopCountdown();
814
+ endedEl?.remove();
815
+ endedEl = null;
816
+ }
817
+ return {
818
+ el,
819
+ showLoading() {
820
+ el.classList.add("sp-loading");
821
+ },
822
+ hideLoading() {
823
+ el.classList.remove("sp-loading");
824
+ },
825
+ showError(onRetry) {
826
+ hideError();
827
+ hideEnded();
828
+ el.classList.remove("sp-loading");
829
+ errorEl = createEl("div", { className: "sp-state-panel", parent: el });
830
+ createEl("div", { className: "sp-state-message", text: i18n.t("loadError"), parent: errorEl });
831
+ const retryBtn = createEl("button", {
832
+ className: "sp-state-btn",
833
+ text: i18n.t("retry"),
834
+ attrs: { type: "button" },
835
+ parent: errorEl
836
+ });
837
+ retryBtn.addEventListener("click", () => {
838
+ hideError();
839
+ onRetry();
840
+ });
841
+ },
842
+ hideError,
843
+ showEnded({ onReplay, onNext, autoNextSeconds }) {
844
+ hideEnded();
845
+ hideError();
846
+ el.classList.remove("sp-loading");
847
+ endedEl = createEl("div", { className: "sp-state-panel", parent: el });
848
+ const buttons = createEl("div", { className: "sp-state-actions", parent: endedEl });
849
+ const replayBtn = createEl("button", {
850
+ className: "sp-state-btn",
851
+ text: i18n.t("replay"),
852
+ attrs: { type: "button" },
853
+ parent: buttons
854
+ });
855
+ replayBtn.addEventListener("click", () => {
856
+ hideEnded();
857
+ onReplay();
858
+ });
859
+ if (onNext) {
860
+ const nextBtn = createEl("button", {
861
+ className: "sp-state-btn sp-state-btn-primary",
862
+ text: i18n.t("playNext"),
863
+ attrs: { type: "button" },
864
+ parent: buttons
865
+ });
866
+ nextBtn.addEventListener("click", () => {
867
+ hideEnded();
868
+ onNext();
869
+ });
870
+ if (autoNextSeconds && autoNextSeconds > 0) {
871
+ let remain = autoNextSeconds;
872
+ const tip = createEl("div", {
873
+ className: "sp-state-countdown",
874
+ text: i18n.t("autoNextIn", { n: remain }),
875
+ parent: endedEl
876
+ });
877
+ const cancelBtn = createEl("button", {
878
+ className: "sp-state-cancel",
879
+ text: i18n.t("cancel"),
880
+ attrs: { type: "button" },
881
+ parent: tip
882
+ });
883
+ cancelBtn.addEventListener("click", () => {
884
+ stopCountdown();
885
+ tip.remove();
886
+ });
887
+ countdownTimer = setInterval(() => {
888
+ remain -= 1;
889
+ if (remain <= 0) {
890
+ hideEnded();
891
+ onNext();
892
+ } else {
893
+ tip.firstChild.textContent = i18n.t("autoNextIn", { n: remain });
894
+ }
895
+ }, 1e3);
896
+ }
897
+ }
898
+ },
899
+ hideEnded,
900
+ destroy() {
901
+ stopCountdown();
902
+ el.remove();
903
+ }
904
+ };
905
+ }
906
+
907
+ // src/i18n/index.ts
908
+ var zhCN = {
909
+ play: "\u64AD\u653E",
910
+ pause: "\u6682\u505C",
911
+ playPause: "\u64AD\u653E/\u6682\u505C (\u7A7A\u683C)",
912
+ prev: "\u4E0A\u4E00\u4E2A",
913
+ next: "\u4E0B\u4E00\u4E2A",
914
+ seekBack: "\u5FEB\u9000 {n} \u79D2 (\u2190)",
915
+ seekForward: "\u5FEB\u8FDB {n} \u79D2 (\u2192)",
916
+ speed: "\u64AD\u653E\u500D\u901F",
917
+ quality: "\u753B\u8D28",
918
+ qualityAuto: "\u81EA\u52A8",
919
+ aspectRatio: "\u753B\u9762\u6BD4\u4F8B",
920
+ ratioOriginal: "\u539F\u59CB",
921
+ audioTrack: "\u97F3\u8F68",
922
+ mute: "\u9759\u97F3 (M)",
923
+ fullscreen: "\u5168\u5C4F (F)",
924
+ pip: "\u753B\u4E2D\u753B (P)",
925
+ empty: "\u6682\u65E0\u53EF\u9009\u9879",
926
+ closeStats: "\u5173\u95ED\u7EDF\u8BA1\u4FE1\u606F",
927
+ seconds: "\u79D2",
928
+ volume: "\u97F3\u91CF",
929
+ muted: "\u9759\u97F3",
930
+ replay: "\u91CD\u65B0\u64AD\u653E",
931
+ playNext: "\u64AD\u653E\u4E0B\u4E00\u4E2A",
932
+ autoNextIn: "{n} \u79D2\u540E\u64AD\u653E\u4E0B\u4E00\u4E2A",
933
+ cancel: "\u53D6\u6D88",
934
+ loadError: "\u89C6\u9891\u52A0\u8F7D\u5931\u8D25",
935
+ retry: "\u91CD\u8BD5"
936
+ };
937
+ var en = {
938
+ play: "Play",
939
+ pause: "Pause",
940
+ playPause: "Play/Pause (Space)",
941
+ prev: "Previous",
942
+ next: "Next",
943
+ seekBack: "Rewind {n}s (\u2190)",
944
+ seekForward: "Forward {n}s (\u2192)",
945
+ speed: "Playback speed",
946
+ quality: "Quality",
947
+ qualityAuto: "Auto",
948
+ aspectRatio: "Aspect ratio",
949
+ ratioOriginal: "Original",
950
+ audioTrack: "Audio track",
951
+ mute: "Mute (M)",
952
+ fullscreen: "Fullscreen (F)",
953
+ pip: "Picture-in-Picture (P)",
954
+ empty: "Nothing available",
955
+ closeStats: "Close stats",
956
+ seconds: "s",
957
+ volume: "Volume",
958
+ muted: "Muted",
959
+ replay: "Replay",
960
+ playNext: "Play next",
961
+ autoNextIn: "Playing next in {n}s",
962
+ cancel: "Cancel",
963
+ loadError: "Failed to load video",
964
+ retry: "Retry"
965
+ };
966
+ var locales = { "zh-CN": zhCN, en };
967
+ var I18n = class {
968
+ constructor(locale = "zh-CN", overrides) {
969
+ this.strings = { ...locales[locale] ?? zhCN, ...overrides };
970
+ }
971
+ t(key, params) {
972
+ let text = this.strings[key];
973
+ if (params) {
974
+ for (const [k, v] of Object.entries(params)) text = text.replace(`{${k}}`, String(v));
975
+ }
976
+ return text;
977
+ }
978
+ };
979
+ function registerLocale(name, strings) {
980
+ locales[name] = strings;
981
+ }
982
+
983
+ // src/utils/injectStyle.ts
984
+ var STYLE_ID = "sweet-player-style";
985
+ function injectStyle(css) {
986
+ if (typeof document === "undefined") return;
987
+ if (document.getElementById(STYLE_ID)) return;
988
+ const style = document.createElement("style");
989
+ style.id = STYLE_ID;
990
+ style.textContent = css;
991
+ document.head.appendChild(style);
992
+ }
993
+
994
+ // src/utils/storage.ts
995
+ var PREFS_KEY = "sweet-player:prefs";
996
+ var PROGRESS_PREFIX = "sweet-player:progress:";
997
+ function safeGet(key) {
998
+ try {
999
+ return localStorage.getItem(key);
1000
+ } catch {
1001
+ return null;
1002
+ }
1003
+ }
1004
+ function safeSet(key, value) {
1005
+ try {
1006
+ localStorage.setItem(key, value);
1007
+ } catch {
1008
+ }
1009
+ }
1010
+ function loadPrefs() {
1011
+ const raw = safeGet(PREFS_KEY);
1012
+ if (!raw) return {};
1013
+ try {
1014
+ return JSON.parse(raw);
1015
+ } catch {
1016
+ return {};
1017
+ }
1018
+ }
1019
+ function savePrefs(prefs) {
1020
+ safeSet(PREFS_KEY, JSON.stringify({ ...loadPrefs(), ...prefs }));
1021
+ }
1022
+ function loadProgress(id) {
1023
+ const raw = safeGet(PROGRESS_PREFIX + id);
1024
+ const n = raw === null ? NaN : Number(raw);
1025
+ return Number.isFinite(n) ? n : null;
1026
+ }
1027
+ function saveProgress(id, time) {
1028
+ safeSet(PROGRESS_PREFIX + id, String(Math.floor(time)));
1029
+ }
1030
+ function clearProgress(id) {
1031
+ try {
1032
+ localStorage.removeItem(PROGRESS_PREFIX + id);
1033
+ } catch {
1034
+ }
1035
+ }
1036
+
1037
+ // src/styles/player.css
1038
+ var player_default = ".sweet-player {\n --sp-accent: #ff4d6d;\n --sp-bg: #000;\n --sp-text: #fff;\n --sp-bar-height: 4px;\n --sp-bar-hover-height: 6px;\n --sp-control-size: 36px;\n --sp-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\n\n position: relative;\n width: 100%;\n height: 100%;\n background: var(--sp-bg);\n color: var(--sp-text);\n font-family: var(--sp-font);\n font-size: 13px;\n overflow: hidden;\n user-select: none;\n -webkit-user-select: none;\n outline: none;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.sweet-player *,\n.sweet-player *::before,\n.sweet-player *::after {\n box-sizing: border-box;\n}\n\n.sp-video {\n width: 100%;\n height: 100%;\n object-fit: contain;\n display: block;\n}\n\n/* \u5F3A\u5236\u753B\u9762\u6BD4\u4F8B\uFF1Avideo \u5C45\u4E2D\u3001\u6309\u6BD4\u4F8B\u9650\u5236\u5C3A\u5BF8\u5E76\u62C9\u4F38\u586B\u5145 */\n.sweet-player[data-ratio]:not([data-ratio='original']) .sp-video {\n width: auto;\n height: auto;\n max-width: 100%;\n max-height: 100%;\n aspect-ratio: var(--sp-forced-ratio);\n object-fit: fill;\n}\n\n/* ---------- \u9876\u90E8\u6807\u9898\u533A ---------- */\n.sp-top {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n padding: 12px 16px 28px;\n background: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent);\n transition: opacity 0.25s ease;\n pointer-events: none;\n}\n\n.sp-title {\n display: inline-block;\n max-width: 70%;\n font-size: 15px;\n font-weight: 500;\n text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n pointer-events: auto;\n cursor: default;\n}\n\n/* ---------- \u7EDF\u8BA1\u4FE1\u606F\u8499\u5C42 ---------- */\n.sp-stats {\n position: absolute;\n top: 12px;\n left: 12px;\n z-index: 30;\n min-width: 300px;\n max-width: calc(100% - 24px);\n padding: 10px 14px;\n background: rgba(0, 0, 0, 0.75);\n border-radius: 6px;\n font-size: 12px;\n line-height: 1.7;\n font-family: 'SFMono-Regular', Consolas, monospace;\n pointer-events: auto;\n}\n\n.sp-stats-close {\n position: absolute;\n top: 6px;\n right: 6px;\n width: 22px;\n height: 22px;\n border: none;\n background: transparent;\n color: var(--sp-text);\n cursor: pointer;\n opacity: 0.7;\n font-size: 16px;\n line-height: 1;\n}\n\n.sp-stats-close:hover {\n opacity: 1;\n}\n\n.sp-stats-row {\n display: flex;\n gap: 12px;\n}\n\n.sp-stats-row dt {\n flex: 0 0 110px;\n opacity: 0.65;\n margin: 0;\n}\n\n.sp-stats-row dd {\n margin: 0;\n word-break: break-all;\n}\n\n/* ---------- \u4E2D\u592E OSD \u53CD\u9988 ---------- */\n.sp-osd {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n z-index: 20;\n padding: 10px 18px;\n background: rgba(0, 0, 0, 0.65);\n border-radius: 6px;\n font-size: 16px;\n font-weight: 500;\n opacity: 0;\n transition: opacity 0.15s ease;\n pointer-events: none;\n white-space: nowrap;\n}\n\n.sp-osd.sp-visible {\n opacity: 1;\n}\n\n/* ---------- \u5E95\u90E8\u63A7\u5236\u533A ---------- */\n.sp-bottom {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n padding: 24px 12px 6px;\n background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);\n transition: opacity 0.25s ease;\n z-index: 10;\n}\n\n.sweet-player.sp-controls-hidden .sp-bottom,\n.sweet-player.sp-controls-hidden .sp-top {\n opacity: 0;\n pointer-events: none;\n}\n\n.sweet-player.sp-controls-hidden {\n cursor: none;\n}\n\n/* ---------- \u8FDB\u5EA6\u6761 ---------- */\n.sp-progress {\n position: relative;\n height: 14px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-bottom: 4px;\n}\n\n.sp-progress-track {\n position: relative;\n width: 100%;\n height: var(--sp-bar-height);\n background: rgba(255, 255, 255, 0.25);\n border-radius: 2px;\n overflow: visible;\n transition: height 0.1s ease;\n}\n\n.sp-progress:hover .sp-progress-track {\n height: var(--sp-bar-hover-height);\n}\n\n.sp-progress-buffered,\n.sp-progress-played {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n border-radius: 2px;\n}\n\n.sp-progress-buffered {\n background: rgba(255, 255, 255, 0.35);\n}\n\n.sp-progress-played {\n background: var(--sp-accent);\n}\n\n.sp-progress-thumb {\n position: absolute;\n top: 50%;\n width: 12px;\n height: 12px;\n border-radius: 50%;\n background: var(--sp-accent);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 0.1s ease;\n pointer-events: none;\n}\n\n.sp-progress:hover .sp-progress-thumb,\n.sp-progress.sp-dragging .sp-progress-thumb {\n transform: translate(-50%, -50%) scale(1);\n}\n\n.sp-progress-tooltip {\n position: absolute;\n bottom: 18px;\n transform: translateX(-50%);\n padding: 2px 6px;\n background: rgba(0, 0, 0, 0.8);\n border-radius: 3px;\n font-size: 12px;\n display: none;\n pointer-events: none;\n white-space: nowrap;\n}\n\n.sp-progress:hover .sp-progress-tooltip {\n display: block;\n}\n\n/* ---------- \u63A7\u5236\u6309\u94AE\u884C ---------- */\n.sp-controls {\n display: flex;\n align-items: center;\n gap: 2px;\n}\n\n.sp-controls-spacer {\n flex: 1;\n}\n\n.sp-btn {\n width: var(--sp-control-size);\n height: var(--sp-control-size);\n border: none;\n background: transparent;\n color: var(--sp-text);\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 4px;\n padding: 0;\n opacity: 0.9;\n transition: opacity 0.1s ease, background 0.1s ease;\n}\n\n.sp-btn:hover {\n opacity: 1;\n background: rgba(255, 255, 255, 0.15);\n}\n\n.sp-btn svg {\n width: 22px;\n height: 22px;\n}\n\n.sp-btn:disabled {\n opacity: 0.35;\n cursor: not-allowed;\n}\n\n.sp-time {\n padding: 0 8px;\n font-variant-numeric: tabular-nums;\n white-space: nowrap;\n opacity: 0.95;\n}\n\n/* ---------- \u5F39\u51FA\u83DC\u5355 ---------- */\n.sp-menu {\n position: relative;\n}\n\n.sp-menu-btn {\n min-width: var(--sp-control-size);\n width: auto;\n padding: 0 8px;\n font-size: 13px;\n font-weight: 500;\n}\n\n.sp-menu-panel {\n position: absolute;\n bottom: calc(100% + 8px);\n left: 50%;\n transform: translateX(-50%);\n min-width: 80px;\n max-height: 220px;\n overflow-y: auto;\n background: rgba(28, 28, 28, 0.95);\n border-radius: 6px;\n padding: 4px 0;\n display: none;\n z-index: 25;\n}\n\n.sp-menu.sp-open .sp-menu-panel {\n display: block;\n}\n\n.sp-menu-item {\n display: block;\n width: 100%;\n padding: 7px 16px;\n border: none;\n background: transparent;\n color: var(--sp-text);\n text-align: center;\n cursor: pointer;\n font-size: 13px;\n white-space: nowrap;\n}\n\n.sp-menu-item:hover {\n background: rgba(255, 255, 255, 0.12);\n}\n\n.sp-menu-item.sp-active {\n color: var(--sp-accent);\n font-weight: 600;\n}\n\n.sp-menu-empty {\n padding: 7px 16px;\n opacity: 0.5;\n white-space: nowrap;\n}\n\n/* ---------- \u72B6\u6001\u8499\u5C42\uFF1Aloading / \u9519\u8BEF / \u7ED3\u675F ---------- */\n.sp-state {\n position: absolute;\n inset: 0;\n z-index: 15;\n display: flex;\n align-items: center;\n justify-content: center;\n pointer-events: none;\n}\n\n.sp-spinner {\n opacity: 0;\n transition: opacity 0.2s ease 0.3s; /* \u5EF6\u8FDF\u51FA\u73B0\uFF0C\u907F\u514D\u77ED\u6682 seek \u95EA\u70C1 */\n}\n\n.sp-state.sp-loading .sp-spinner {\n opacity: 1;\n}\n\n.sp-spinner-ring {\n width: 44px;\n height: 44px;\n border: 4px solid rgba(255, 255, 255, 0.25);\n border-top-color: var(--sp-accent);\n border-radius: 50%;\n animation: sp-spin 0.8s linear infinite;\n}\n\n@keyframes sp-spin {\n to {\n transform: rotate(360deg);\n }\n}\n\n.sp-state-panel {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 14px;\n background: rgba(0, 0, 0, 0.55);\n pointer-events: auto;\n}\n\n.sp-state-message {\n font-size: 15px;\n opacity: 0.95;\n}\n\n.sp-state-actions {\n display: flex;\n gap: 12px;\n}\n\n.sp-state-btn {\n padding: 8px 22px;\n border: 1px solid rgba(255, 255, 255, 0.5);\n border-radius: 18px;\n background: transparent;\n color: var(--sp-text);\n font-size: 14px;\n cursor: pointer;\n transition: background 0.1s ease;\n}\n\n.sp-state-btn:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.sp-state-btn-primary {\n background: var(--sp-accent);\n border-color: var(--sp-accent);\n}\n\n.sp-state-btn-primary:hover {\n background: var(--sp-accent);\n filter: brightness(1.1);\n}\n\n.sp-state-countdown {\n font-size: 13px;\n opacity: 0.85;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.sp-state-cancel {\n border: none;\n background: transparent;\n color: var(--sp-accent);\n cursor: pointer;\n font-size: 13px;\n padding: 2px 6px;\n}\n\n/* ---------- \u97F3\u91CF ---------- */\n.sp-volume {\n display: flex;\n align-items: center;\n}\n\n.sp-volume-slider {\n width: 0;\n overflow: hidden;\n transition: width 0.15s ease;\n display: flex;\n align-items: center;\n}\n\n.sp-volume:hover .sp-volume-slider,\n.sp-volume.sp-dragging .sp-volume-slider {\n width: 64px;\n}\n\n.sp-volume-track {\n position: relative;\n width: 56px;\n height: var(--sp-bar-height);\n margin: 0 4px;\n background: rgba(255, 255, 255, 0.25);\n border-radius: 2px;\n cursor: pointer;\n}\n\n.sp-volume-fill {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n background: var(--sp-text);\n border-radius: 2px;\n}\n\n.sp-volume-thumb {\n position: absolute;\n top: 50%;\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--sp-text);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n";
1039
+
1040
+ // src/player.ts
1041
+ var DEFAULT_RATES = [0.5, 1, 1.5, 2];
1042
+ var DEFAULT_RATIOS = ["original", "21:9", "16:9", "4:3"];
1043
+ var DEFAULT_LONG_SEEK_STEPS = [10, 30, 60];
1044
+ var DEFAULT_STEP_UP_INTERVAL = 2e3;
1045
+ var DEFAULT_AUTO_NEXT_SECONDS = 5;
1046
+ var CONTROLS_HIDE_DELAY = 3e3;
1047
+ var TITLE_CLICK_COUNT = 10;
1048
+ var TITLE_CLICK_WINDOW = 1500;
1049
+ var SINGLE_CLICK_DELAY = 250;
1050
+ var PROGRESS_SAVE_INTERVAL = 5e3;
1051
+ var PROGRESS_END_GUARD = 10;
1052
+ var SweetPlayer = class {
1053
+ constructor(options) {
1054
+ this.emitter = new EventEmitter();
1055
+ this.hideTimer = null;
1056
+ this.clickTimer = null;
1057
+ this.progressTimer = null;
1058
+ this.titleClicks = 0;
1059
+ this.lastTitleClick = 0;
1060
+ this.currentRatio = "original";
1061
+ /** 画质/音轨菜单当前是否由 hls.js 自动接管(业务 setQualities 会关闭) */
1062
+ this.hlsManagedQuality = false;
1063
+ this.hlsManagedAudio = false;
1064
+ this.disposers = [];
1065
+ this.pluginCleanups = [];
1066
+ this.destroyed = false;
1067
+ this.options = options;
1068
+ this.i18n = new I18n(options.locale, options.localeStrings);
1069
+ const target = typeof options.container === "string" ? document.querySelector(options.container) : options.container;
1070
+ if (!target) throw new Error(`[sweet-player] container not found: ${options.container}`);
1071
+ injectStyle(player_default);
1072
+ this.container = createEl("div", {
1073
+ className: "sweet-player",
1074
+ attrs: { tabindex: "0", "data-ratio": "original" },
1075
+ parent: target
1076
+ });
1077
+ this.video = createEl("video", {
1078
+ className: "sp-video",
1079
+ attrs: { playsinline: "" },
1080
+ parent: this.container
1081
+ });
1082
+ const persist = options.persist !== false;
1083
+ const prefs = persist ? loadPrefs() : {};
1084
+ this.video.volume = clamp(prefs.volume ?? options.volume ?? 100, 0, 100) / 100;
1085
+ this.video.muted = prefs.muted ?? options.muted ?? false;
1086
+ if (options.autoplay) this.video.autoplay = true;
1087
+ const autoQuality = options.autoQuality !== false;
1088
+ this.media = new MediaController(this.video, this.emitter, options.hlsConfig, {
1089
+ onLevels: autoQuality && !options.qualities?.length ? (levels) => this.applyHlsLevels(levels) : void 0,
1090
+ onAudioTracks: autoQuality && !options.audioTracks?.length ? (tracks) => this.applyHlsAudioTracks(tracks) : void 0
1091
+ });
1092
+ this.osd = createOsd();
1093
+ this.stats = createStatsOverlay(this.container, this.video, this.media, this.i18n);
1094
+ this.state = createStateOverlay(this.i18n);
1095
+ this.controls = createControls({
1096
+ video: this.video,
1097
+ title: options.title ?? "",
1098
+ i18n: this.i18n,
1099
+ playbackRates: options.playbackRates ?? DEFAULT_RATES,
1100
+ aspectRatios: options.aspectRatios ?? DEFAULT_RATIOS,
1101
+ seekStep: options.seekStep ?? 10,
1102
+ actions: {
1103
+ togglePlay: () => this.toggle(),
1104
+ seekBy: (d) => this.seekBy(d),
1105
+ setRate: (r) => this.setRate(r),
1106
+ setVolume: (v) => this.setVolume(v),
1107
+ toggleMute: () => this.setMuted(!this.video.muted),
1108
+ setAspectRatio: (r) => this.setAspectRatio(r),
1109
+ toggleFullscreen: () => this.toggleFullscreen(),
1110
+ togglePip: () => this.togglePip(),
1111
+ selectQuality: (q) => this.handleQualitySelect(q),
1112
+ selectAudioTrack: (t) => this.handleAudioTrackSelect(t),
1113
+ onPrev: options.onPrev,
1114
+ onNext: options.onNext,
1115
+ onTitleClick: () => this.handleTitleClick()
1116
+ }
1117
+ });
1118
+ this.container.appendChild(this.controls.topEl);
1119
+ this.container.appendChild(this.controls.bottomEl);
1120
+ this.container.appendChild(this.state.el);
1121
+ this.container.appendChild(this.osd.el);
1122
+ if (options.qualities?.length) this.setQualities(options.qualities);
1123
+ if (options.audioTracks?.length) this.setAudioTracks(options.audioTracks);
1124
+ const seekStep = options.seekStep ?? 10;
1125
+ this.keyboard = new KeyboardController(
1126
+ this.container,
1127
+ {
1128
+ togglePlay: () => this.toggle(),
1129
+ seekBy: (d) => this.seekBy(d),
1130
+ onLongSeekProgress: (acc) => this.osd.show(`${acc > 0 ? "+" : ""}${acc} ${this.i18n.t("seconds")}`),
1131
+ onLongSeekCommit: (acc) => {
1132
+ this.osd.hide();
1133
+ if (acc !== 0) this.seekBy(acc, false);
1134
+ },
1135
+ adjustVolume: (d) => this.setVolume(Math.round(this.video.volume * 100) + d),
1136
+ toggleFullscreen: () => this.toggleFullscreen(),
1137
+ toggleMute: () => this.setMuted(!this.video.muted)
1138
+ },
1139
+ {
1140
+ seekStep,
1141
+ longSeekSteps: options.longSeek?.steps ?? DEFAULT_LONG_SEEK_STEPS,
1142
+ stepUpInterval: options.longSeek?.stepUpInterval ?? DEFAULT_STEP_UP_INTERVAL
1143
+ }
1144
+ );
1145
+ this.gestures = new GestureController(
1146
+ this.container,
1147
+ this.video,
1148
+ {
1149
+ seekBy: (d) => this.seekBy(d),
1150
+ onSeekPreview: (d) => this.osd.show(
1151
+ `${d > 0 ? "+" : ""}${d} ${this.i18n.t("seconds")} (${formatTime(
1152
+ clamp(this.video.currentTime + d, 0, this.video.duration || 0)
1153
+ )})`
1154
+ ),
1155
+ onSeekCommit: (d) => {
1156
+ this.osd.hide();
1157
+ if (d !== 0) this.seekBy(d, false);
1158
+ },
1159
+ adjustVolume: (d) => this.setVolume(Math.round(this.video.volume * 100) + d),
1160
+ toggleControls: () => {
1161
+ this.container.classList.contains("sp-controls-hidden") ? this.scheduleHide() : this.hideControlsNow();
1162
+ },
1163
+ toggleFullscreen: () => this.toggleFullscreen()
1164
+ },
1165
+ seekStep
1166
+ );
1167
+ this.bindMediaEvents();
1168
+ this.bindActivityTracking();
1169
+ this.disposers.push(
1170
+ onFullscreenChange(this.container, (fs) => {
1171
+ this.controls.updateFullscreen(fs);
1172
+ this.emitter.emit("fullscreenchange", fs);
1173
+ })
1174
+ );
1175
+ if (persist && prefs.rate) {
1176
+ this.video.defaultPlaybackRate = prefs.rate;
1177
+ this.video.playbackRate = prefs.rate;
1178
+ }
1179
+ this.media.load(options.src);
1180
+ this.controls.volume.update(Math.round(this.video.volume * 100), this.video.muted);
1181
+ this.controls.updateRate(this.video.playbackRate);
1182
+ if (persist) this.bindPersistence();
1183
+ if (options.id) this.bindProgressMemory(options.id);
1184
+ options.plugins?.forEach((p) => this.use(p));
1185
+ }
1186
+ // ---------- 公开 API ----------
1187
+ play() {
1188
+ return this.video.play();
1189
+ }
1190
+ pause() {
1191
+ this.video.pause();
1192
+ }
1193
+ toggle() {
1194
+ this.video.paused ? void this.play().catch(() => {
1195
+ }) : this.pause();
1196
+ }
1197
+ seek(time) {
1198
+ this.video.currentTime = clamp(time, 0, this.video.duration || 0);
1199
+ }
1200
+ /** 相对跳转;showOsd 为 false 时不显示中央提示 */
1201
+ seekBy(delta, showOsd = true) {
1202
+ this.seek(this.video.currentTime + delta);
1203
+ if (showOsd) this.osd.flash(`${delta > 0 ? "+" : ""}${delta} ${this.i18n.t("seconds")}`);
1204
+ }
1205
+ setRate(rate) {
1206
+ this.video.playbackRate = rate;
1207
+ }
1208
+ /** 0-100 */
1209
+ setVolume(volume) {
1210
+ const v = clamp(volume, 0, 100);
1211
+ this.video.volume = v / 100;
1212
+ if (v > 0) this.video.muted = false;
1213
+ this.osd.flash(`${this.i18n.t("volume")} ${v}%`);
1214
+ }
1215
+ setMuted(muted) {
1216
+ this.video.muted = muted;
1217
+ this.osd.flash(muted ? this.i18n.t("muted") : `${this.i18n.t("volume")} ${Math.round(this.video.volume * 100)}%`);
1218
+ }
1219
+ setAspectRatio(ratio) {
1220
+ this.currentRatio = ratio;
1221
+ this.container.setAttribute("data-ratio", ratio);
1222
+ if (ratio !== "original") {
1223
+ this.container.style.setProperty("--sp-forced-ratio", ratio.replace(":", " / "));
1224
+ }
1225
+ this.controls.updateRatio(ratio);
1226
+ this.emitter.emit("aspectratiochange", ratio);
1227
+ }
1228
+ get aspectRatio() {
1229
+ return this.currentRatio;
1230
+ }
1231
+ /** 运行时更新画质列表 */
1232
+ setQualities(qualities, active) {
1233
+ this.hlsManagedQuality = false;
1234
+ this.controls.qualityMenu.setItems(qualities.map((q) => ({ label: q.label, value: q })));
1235
+ if (active) this.controls.qualityMenu.setActive(active);
1236
+ }
1237
+ /** 运行时更新音轨列表 */
1238
+ setAudioTracks(tracks, active) {
1239
+ this.hlsManagedAudio = false;
1240
+ this.controls.audioMenu.setItems(tracks.map((t) => ({ label: t.label, value: t })));
1241
+ if (active) this.controls.audioMenu.setActive(active);
1242
+ }
1243
+ async toggleFullscreen() {
1244
+ await toggleFullscreen(this.container).catch(() => {
1245
+ });
1246
+ }
1247
+ get fullscreen() {
1248
+ return isFullscreen(this.container);
1249
+ }
1250
+ async togglePip() {
1251
+ try {
1252
+ if (document.pictureInPictureElement === this.video) {
1253
+ await document.exitPictureInPicture();
1254
+ } else {
1255
+ await this.video.requestPictureInPicture();
1256
+ }
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ load(src) {
1261
+ this.state.hideEnded();
1262
+ this.state.hideError();
1263
+ this.media.load(src);
1264
+ }
1265
+ setTitle(title) {
1266
+ const el = this.controls.topEl.querySelector(".sp-title");
1267
+ if (el) el.textContent = title;
1268
+ }
1269
+ /** 安装插件;返回本次插件的卸载函数 */
1270
+ use(plugin) {
1271
+ const cleanup = plugin.apply(this);
1272
+ const dispose = typeof cleanup === "function" ? cleanup : () => {
1273
+ };
1274
+ this.pluginCleanups.push(dispose);
1275
+ return dispose;
1276
+ }
1277
+ on(event, fn) {
1278
+ return this.emitter.on(event, fn);
1279
+ }
1280
+ off(event, fn) {
1281
+ this.emitter.off(event, fn);
1282
+ }
1283
+ destroy() {
1284
+ if (this.destroyed) return;
1285
+ this.destroyed = true;
1286
+ this.saveProgressNow();
1287
+ this.emitter.emit("destroy", void 0);
1288
+ this.pluginCleanups.forEach((d) => d());
1289
+ this.keyboard.destroy();
1290
+ this.gestures.destroy();
1291
+ this.stats.destroy();
1292
+ this.state.destroy();
1293
+ this.controls.destroy();
1294
+ this.disposers.forEach((d) => d());
1295
+ if (this.hideTimer) clearTimeout(this.hideTimer);
1296
+ if (this.clickTimer) clearTimeout(this.clickTimer);
1297
+ if (this.progressTimer) clearInterval(this.progressTimer);
1298
+ this.media.destroy();
1299
+ this.emitter.removeAll();
1300
+ this.container.remove();
1301
+ }
1302
+ // ---------- 内部:hls 自动画质/音轨 ----------
1303
+ applyHlsLevels(levels) {
1304
+ const auto = { label: this.i18n.t("qualityAuto"), value: -1 };
1305
+ const items = [auto, ...levels.map((l) => ({ label: l.label, value: l.index }))];
1306
+ this.setQualities(items, auto);
1307
+ this.hlsManagedQuality = true;
1308
+ }
1309
+ applyHlsAudioTracks(tracks) {
1310
+ const items = tracks.map((t) => ({ label: t.label, value: t.index }));
1311
+ this.setAudioTracks(items, items[0]);
1312
+ this.hlsManagedAudio = true;
1313
+ }
1314
+ handleQualitySelect(quality) {
1315
+ this.controls.qualityMenu.setActive(quality);
1316
+ if (this.hlsManagedQuality && typeof quality.value === "number") {
1317
+ this.media.setLevel(quality.value);
1318
+ } else if (quality.src) {
1319
+ const time = this.video.currentTime;
1320
+ this.media.load(quality.src);
1321
+ this.video.currentTime = time;
1322
+ }
1323
+ this.options.onQualityChange?.(quality);
1324
+ this.emitter.emit("qualitychange", quality);
1325
+ }
1326
+ handleAudioTrackSelect(track) {
1327
+ this.controls.audioMenu.setActive(track);
1328
+ if (this.hlsManagedAudio && typeof track.value === "number") {
1329
+ this.media.setAudioTrack(track.value);
1330
+ }
1331
+ this.options.onAudioTrackChange?.(track);
1332
+ this.emitter.emit("audiotrackchange", track);
1333
+ }
1334
+ // ---------- 内部:持久化 ----------
1335
+ bindPersistence() {
1336
+ const onVolume = () => savePrefs({ volume: Math.round(this.video.volume * 100), muted: this.video.muted });
1337
+ const onRate = () => savePrefs({ rate: this.video.playbackRate });
1338
+ this.video.addEventListener("volumechange", onVolume);
1339
+ this.video.addEventListener("ratechange", onRate);
1340
+ this.disposers.push(() => {
1341
+ this.video.removeEventListener("volumechange", onVolume);
1342
+ this.video.removeEventListener("ratechange", onRate);
1343
+ });
1344
+ }
1345
+ bindProgressMemory(id) {
1346
+ const restore = () => {
1347
+ const saved = loadProgress(id);
1348
+ if (saved !== null && saved > 3 && saved < this.video.duration - PROGRESS_END_GUARD) {
1349
+ this.video.currentTime = saved;
1350
+ }
1351
+ };
1352
+ this.video.addEventListener("loadedmetadata", restore, { once: true });
1353
+ this.progressTimer = setInterval(() => {
1354
+ if (!this.video.paused) this.saveProgressNow();
1355
+ }, PROGRESS_SAVE_INTERVAL);
1356
+ }
1357
+ saveProgressNow() {
1358
+ const id = this.options.id;
1359
+ if (!id || !this.video.duration) return;
1360
+ if (this.video.currentTime >= this.video.duration - PROGRESS_END_GUARD) {
1361
+ clearProgress(id);
1362
+ } else if (this.video.currentTime > 3) {
1363
+ saveProgress(id, this.video.currentTime);
1364
+ }
1365
+ }
1366
+ // ---------- 内部:交互 ----------
1367
+ handleTitleClick() {
1368
+ const now = Date.now();
1369
+ this.titleClicks = now - this.lastTitleClick <= TITLE_CLICK_WINDOW ? this.titleClicks + 1 : 1;
1370
+ this.lastTitleClick = now;
1371
+ if (this.titleClicks >= TITLE_CLICK_COUNT) {
1372
+ this.titleClicks = 0;
1373
+ this.stats.toggle();
1374
+ }
1375
+ }
1376
+ bindMediaEvents() {
1377
+ const v = this.video;
1378
+ const listen = (event, fn) => {
1379
+ v.addEventListener(event, fn);
1380
+ this.disposers.push(() => v.removeEventListener(event, fn));
1381
+ };
1382
+ listen("loadedmetadata", () => {
1383
+ this.controls.updateTime();
1384
+ this.emitter.emit("ready", void 0);
1385
+ });
1386
+ listen("play", () => {
1387
+ this.state.hideEnded();
1388
+ this.controls.updatePlayState(true);
1389
+ this.scheduleHide();
1390
+ this.emitter.emit("play", void 0);
1391
+ });
1392
+ listen("pause", () => {
1393
+ this.controls.updatePlayState(false);
1394
+ this.showControls();
1395
+ this.emitter.emit("pause", void 0);
1396
+ });
1397
+ listen("ended", () => {
1398
+ this.saveProgressNow();
1399
+ this.showControls();
1400
+ const onNext = this.options.onNext;
1401
+ const autoNext = this.options.autoNext;
1402
+ this.state.showEnded({
1403
+ onReplay: () => {
1404
+ this.seek(0);
1405
+ void this.play().catch(() => {
1406
+ });
1407
+ },
1408
+ onNext,
1409
+ autoNextSeconds: onNext && autoNext ? typeof autoNext === "number" ? autoNext : DEFAULT_AUTO_NEXT_SECONDS : void 0
1410
+ });
1411
+ this.emitter.emit("ended", void 0);
1412
+ });
1413
+ listen("timeupdate", () => {
1414
+ this.controls.updateTime();
1415
+ this.emitter.emit("timeupdate", { currentTime: v.currentTime, duration: v.duration });
1416
+ });
1417
+ listen("progress", () => this.controls.progress.update());
1418
+ listen("ratechange", () => {
1419
+ this.controls.updateRate(v.playbackRate);
1420
+ this.emitter.emit("ratechange", v.playbackRate);
1421
+ });
1422
+ listen("volumechange", () => {
1423
+ const volume = Math.round(v.volume * 100);
1424
+ this.controls.volume.update(volume, v.muted);
1425
+ this.emitter.emit("volumechange", { volume, muted: v.muted });
1426
+ });
1427
+ listen("waiting", () => this.state.showLoading());
1428
+ listen("stalled", () => this.state.showLoading());
1429
+ listen("seeking", () => this.state.showLoading());
1430
+ listen("canplay", () => this.state.hideLoading());
1431
+ listen("playing", () => {
1432
+ this.state.hideLoading();
1433
+ this.state.hideError();
1434
+ });
1435
+ listen("seeked", () => this.state.hideLoading());
1436
+ listen("error", () => this.showErrorState({ type: "media", detail: v.error }));
1437
+ this.disposers.push(
1438
+ this.emitter.on("error", (payload) => {
1439
+ if (payload.type.startsWith("hls-")) this.showErrorStateUi();
1440
+ })
1441
+ );
1442
+ listen("enterpictureinpicture", () => this.emitter.emit("pipchange", true));
1443
+ listen("leavepictureinpicture", () => this.emitter.emit("pipchange", false));
1444
+ const onVideoClick = (e) => {
1445
+ if (e.target !== this.video || e.pointerType === "touch") return;
1446
+ if (this.clickTimer) return;
1447
+ this.clickTimer = setTimeout(() => {
1448
+ this.clickTimer = null;
1449
+ this.toggle();
1450
+ }, SINGLE_CLICK_DELAY);
1451
+ };
1452
+ const onVideoDblClick = (e) => {
1453
+ if (e.target !== this.video) return;
1454
+ if (this.clickTimer) {
1455
+ clearTimeout(this.clickTimer);
1456
+ this.clickTimer = null;
1457
+ }
1458
+ void this.toggleFullscreen();
1459
+ };
1460
+ this.container.addEventListener("click", onVideoClick);
1461
+ this.container.addEventListener("dblclick", onVideoDblClick);
1462
+ this.disposers.push(() => {
1463
+ this.container.removeEventListener("click", onVideoClick);
1464
+ this.container.removeEventListener("dblclick", onVideoDblClick);
1465
+ });
1466
+ }
1467
+ showErrorState(payload) {
1468
+ this.emitter.emit("error", payload);
1469
+ this.showErrorStateUi();
1470
+ }
1471
+ showErrorStateUi() {
1472
+ this.state.hideLoading();
1473
+ this.state.showError(() => {
1474
+ const time = this.video.currentTime;
1475
+ this.media.reload();
1476
+ if (time > 0) this.video.currentTime = time;
1477
+ void this.play().catch(() => {
1478
+ });
1479
+ });
1480
+ }
1481
+ // ---------- 控制栏自动隐藏 ----------
1482
+ showControls() {
1483
+ this.container.classList.remove("sp-controls-hidden");
1484
+ if (this.hideTimer) {
1485
+ clearTimeout(this.hideTimer);
1486
+ this.hideTimer = null;
1487
+ }
1488
+ }
1489
+ hideControlsNow() {
1490
+ if (!this.video.paused) this.container.classList.add("sp-controls-hidden");
1491
+ }
1492
+ scheduleHide() {
1493
+ this.showControls();
1494
+ this.hideTimer = setTimeout(() => this.hideControlsNow(), CONTROLS_HIDE_DELAY);
1495
+ }
1496
+ bindActivityTracking() {
1497
+ const onActivity = (e) => {
1498
+ if (e.pointerType === "touch") return;
1499
+ if (this.video.paused) this.showControls();
1500
+ else this.scheduleHide();
1501
+ };
1502
+ this.container.addEventListener("pointermove", onActivity);
1503
+ this.container.addEventListener("pointerdown", onActivity);
1504
+ this.disposers.push(() => {
1505
+ this.container.removeEventListener("pointermove", onActivity);
1506
+ this.container.removeEventListener("pointerdown", onActivity);
1507
+ });
1508
+ }
1509
+ };
1510
+ export {
1511
+ SweetPlayer,
1512
+ registerLocale
1513
+ };