@yuuichi/openlist-assets 4.2.6-ym.3 → 4.2.6-ym.4

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.
@@ -0,0 +1,876 @@
1
+ /* 统一背景音乐 Dock:本地 / 网易云 / QQ音乐 / Spotify 多源切换
2
+ 双端统一:左下角圆形按钮,点击展开面板(移动端=底部全宽,桌面端=按钮上方浮层) */
3
+ (function () {
4
+ var STORE_POS = 'bgm-dock-pos-v2'
5
+ var STORE_SRC = 'bgm-dock-source'
6
+ var STORE_SIZE = 'bgm-dock-size'
7
+ var DRAG_THRESHOLD = 6
8
+ var MIN_W = 260
9
+ var MAX_W = 560
10
+ var MIN_H = 180
11
+ var MAX_H = 760
12
+ var MOBILE_MAX = 768
13
+
14
+ var root = document.getElementById('bgm-dock')
15
+ if (!root) return
16
+
17
+ var cfg
18
+ try {
19
+ cfg = JSON.parse(root.getAttribute('data-bgm') || '{}')
20
+ } catch (e) {
21
+ cfg = {}
22
+ }
23
+
24
+ var sources = Array.isArray(cfg.sources) ? cfg.sources : []
25
+ if (!sources.length) return
26
+
27
+ // 资源基址:blog 本地为同源根路径;welcome 页经 /__blog/ 代理。
28
+ // 优先读取配置中的 assetBase,其次从脚本自身地址推导(…/js/bgm-dock.js -> 前缀)。
29
+ function detectAssetBase() {
30
+ if (cfg.assetBase) return cfg.assetBase.replace(/\/$/, '')
31
+ try {
32
+ var me = document.currentScript && document.currentScript.src
33
+ if (!me) {
34
+ var s = document.querySelector('script[src*="bgm-dock.js"]')
35
+ me = s && s.src
36
+ }
37
+ if (me) {
38
+ // welcome 页:…/__blog/js/bgm-dock.js -> 前缀为 '/__blog'
39
+ var wp = me.match(/^(https?:\/\/[^/]+)\/__blog\/js\/bgm-dock\.js(?:[?#].*)?$/)
40
+ if (wp) return '/__blog'
41
+ // 博客同源:…/js/bgm-dock.js -> 空前缀
42
+ var same = me.match(/^https?:\/\/[^/]+\/js\/bgm-dock\.js(?:[?#].*)?$/)
43
+ if (same) return ''
44
+ }
45
+ } catch (e) {
46
+ // ignore
47
+ }
48
+ return ''
49
+ }
50
+ var ASSET_BASE = detectAssetBase()
51
+ var ASSET_V = (typeof window.__ASSET_VERSION__ === 'string' && window.__ASSET_VERSION__) || ''
52
+ function assetUrl(p) {
53
+ return ASSET_BASE + p + (ASSET_V ? (p.indexOf('?') >= 0 ? '&' : '?') + 'v=' + ASSET_V : '')
54
+ }
55
+
56
+ function loadStyleOnce(href) {
57
+ var existing = document.querySelector('link[data-bgm-asset="' + href + '"]')
58
+ if (existing) return Promise.resolve()
59
+ return new Promise(function (resolve, reject) {
60
+ var link = document.createElement('link')
61
+ link.rel = 'stylesheet'
62
+ link.href = href
63
+ link.dataset.bgmAsset = href
64
+ link.onload = function () { link.dataset.loaded = '1'; resolve() }
65
+ link.onerror = function () { reject(new Error('css load fail: ' + href)) }
66
+ document.head.appendChild(link)
67
+ })
68
+ }
69
+
70
+ function loadScriptOnce(src, isReady) {
71
+ var existing = document.querySelector('script[data-bgm-asset="' + src + '"]')
72
+ if (existing) {
73
+ if (existing.dataset.loaded === '1' || (isReady && isReady())) return Promise.resolve()
74
+ return new Promise(function (resolve) {
75
+ function check() {
76
+ if (!isReady || isReady()) {
77
+ existing.dataset.loaded = '1'
78
+ resolve()
79
+ } else {
80
+ setTimeout(check, 60)
81
+ }
82
+ }
83
+ check()
84
+ })
85
+ }
86
+ return new Promise(function (resolve, reject) {
87
+ var s = document.createElement('script')
88
+ s.src = src
89
+ s.defer = true
90
+ s.dataset.bgmAsset = src
91
+ s.onload = function () { s.dataset.loaded = '1'; resolve() }
92
+ s.onerror = function () { reject(new Error('script load fail: ' + src)) }
93
+ document.body.appendChild(s)
94
+ })
95
+ }
96
+
97
+ var dockCssLoaded = null
98
+ function loadDockCss() {
99
+ if (dockCssLoaded) return dockCssLoaded
100
+ if (document.getElementById('bgm-dock-full-css')) return Promise.resolve()
101
+ var href = assetUrl('/css/bgm-dock.css')
102
+ dockCssLoaded = loadStyleOnce(href).then(function () {
103
+ var l = document.querySelector('link[data-bgm-asset="' + href + '"]')
104
+ if (l) l.id = 'bgm-dock-full-css'
105
+ })
106
+ return dockCssLoaded
107
+ }
108
+
109
+ var aplayerLoading = null
110
+ function loadAPlayer() {
111
+ if (window.APlayer) return Promise.resolve()
112
+ if (aplayerLoading) return aplayerLoading
113
+ aplayerLoading = Promise.all([
114
+ loadStyleOnce(assetUrl('/js/lib/aplayer/APlayer.min.css')),
115
+ loadScriptOnce(assetUrl('/js/lib/aplayer/APlayer.min.js'), function () { return !!window.APlayer }),
116
+ ]).then(function () {
117
+ if (!window.APlayer) throw new Error('aplayer missing after load')
118
+ })
119
+ return aplayerLoading
120
+ }
121
+
122
+ var built = {}
123
+ var instances = []
124
+ var current = null
125
+ var pending = null
126
+ var dragging = false
127
+ var suppressClick = false
128
+ var opened = false
129
+ var mounted = false
130
+ var pendingKey = null
131
+ var peekRevealed = false
132
+ var swallowPeekClick = false
133
+
134
+ // 暴露 Dock 内的 APlayer 实例,便于倍速控制与 PJAX 检测
135
+ window.__bgmDock = { instances: instances }
136
+
137
+ /* ---------- 构建外壳 ---------- */
138
+ root.innerHTML =
139
+ '<button type="button" class="bgm-dock__fab" aria-label="背景音乐">' +
140
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z"/></svg>' +
141
+ '<span class="bgm-dock__fab-bar"></span>' +
142
+ '</button>' +
143
+ '<div class="bgm-dock__panel">' +
144
+ '<div class="bgm-dock__head">' +
145
+ '<div class="bgm-dock__handle"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 13c0 1.105-1.12 2-2.5 2S1 14.105 1 13s1.12-2 2.5-2 2.5.9 2.5 2zm9-2c0 1.105-1.12 2-2.5 2s-2.5-.9-2.5-2 1.12-2 2.5-2 2.5.9 2.5 2z"/><path fill-rule="evenodd" d="M14 11V2h1v9h-1zM6 3v10H5V3h1z"/><path d="M5 2.905a1 1 0 0 1 .9-.995l9-1a1 1 0 0 1 1.096.995V3L6 4v-1.095z"/></svg><span class="bgm-dock__title">背景音乐</span></div>' +
146
+ '<div class="bgm-dock__tabs"></div>' +
147
+ '<button type="button" class="bgm-dock__toggle" aria-label="折叠">−</button>' +
148
+ '</div>' +
149
+ '<div class="bgm-dock__body"></div>' +
150
+ '<div class="bgm-dock__resize" title="拖动缩放,双击还原"></div>' +
151
+ '</div>'
152
+
153
+ var headEl = root.querySelector('.bgm-dock__head')
154
+ var handleEl = root.querySelector('.bgm-dock__handle')
155
+ var tabsEl = root.querySelector('.bgm-dock__tabs')
156
+ var toggleEl = root.querySelector('.bgm-dock__toggle')
157
+ var bodyEl = root.querySelector('.bgm-dock__body')
158
+ var resizeEl = root.querySelector('.bgm-dock__resize')
159
+ var fabEl = root.querySelector('.bgm-dock__fab')
160
+ var panelEl = root.querySelector('.bgm-dock__panel')
161
+
162
+ /* ---------- 响应式 ---------- */
163
+ var mobileMQ = window.matchMedia
164
+ ? window.matchMedia('(max-width:' + MOBILE_MAX + 'px)')
165
+ : { matches: false }
166
+
167
+ function isMobile() {
168
+ return mobileMQ.matches
169
+ }
170
+
171
+ function clamp(v, min, max) {
172
+ return Math.min(Math.max(v, min), max)
173
+ }
174
+
175
+ /* ---------- 拖拽时的全屏覆盖层(防 iframe/页面吞事件) ---------- */
176
+ var overlay = document.createElement('div')
177
+ overlay.className = 'bgm-dock__overlay'
178
+
179
+ /* ---------- 圆形按钮定位(双端共用,左下角默认) ---------- */
180
+ // root 本身不占布局尺寸:FAB 与展开面板都是相对它的绝对定位子元素,
181
+ // 因此拖拽边界必须按“当前可见子元素相对 root 的外接盒”计算,
182
+ // 不能直接用 root.offsetWidth/Height(展开时为 0,会导致面板拖不下去)。
183
+ function visibleBounds() {
184
+ var boxes = []
185
+ var children = [fabEl, panelEl]
186
+ for (var i = 0; i < children.length; i++) {
187
+ var c = children[i]
188
+ if (!c || c.offsetParent === null) continue
189
+ var st = c.ownerDocument.defaultView.getComputedStyle(c)
190
+ if (st.display === 'none' || st.visibility === 'hidden') continue
191
+ var rect = c.getBoundingClientRect()
192
+ if (rect.width === 0 && rect.height === 0) continue
193
+ // 用 offset 位置,得到相对 root 的坐标
194
+ boxes.push({
195
+ left: c.offsetLeft,
196
+ top: c.offsetTop,
197
+ right: c.offsetLeft + rect.width,
198
+ bottom: c.offsetTop + rect.height,
199
+ })
200
+ }
201
+ if (!boxes.length) {
202
+ return { left: 0, top: 0, width: fabEl.offsetWidth || 52, height: fabEl.offsetHeight || 52 }
203
+ }
204
+ var minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity
205
+ boxes.forEach(function (b) {
206
+ minL = Math.min(minL, b.left)
207
+ minT = Math.min(minT, b.top)
208
+ maxR = Math.max(maxR, b.right)
209
+ maxB = Math.max(maxB, b.bottom)
210
+ })
211
+ return { left: minL, top: minT, width: maxR - minL, height: maxB - minT }
212
+ }
213
+
214
+ function placeFab(left, top) {
215
+ // 入参为 root 的视口坐标。展开时面板相对 root 为负偏移(root 高度为 0),
216
+ // 故先换算成“可见外接盒”视口位置做边界约束,再还原成 root 的定位坐标。
217
+ var b = visibleBounds()
218
+ var w = b.width || fabEl.offsetWidth || 52
219
+ var h = b.height || fabEl.offsetHeight || 52
220
+ var visLeft = clamp(left + b.left, 0, Math.max(0, window.innerWidth - w))
221
+ var visTop = clamp(top + b.top, 0, Math.max(0, window.innerHeight - h))
222
+ var rootLeft = visLeft - b.left
223
+ var rootTop = visTop - b.top
224
+ root.style.left = rootLeft + 'px'
225
+ root.style.right = 'auto'
226
+ root.style.top = rootTop + 'px'
227
+ root.style.bottom = 'auto'
228
+ root.style.margin = '0'
229
+ return { left: rootLeft, top: rootTop }
230
+ }
231
+
232
+ function applySavedPosition() {
233
+ try {
234
+ var s = JSON.parse(localStorage.getItem(STORE_POS) || 'null')
235
+ if (s && typeof s.left === 'number') {
236
+ placeFab(s.left, s.top)
237
+ return
238
+ }
239
+ } catch (e) {
240
+ // ignore
241
+ }
242
+ // 默认左下角
243
+ placeFab(16, window.innerHeight - 68)
244
+ }
245
+
246
+ /* ---------- 收起态半吸附边缘 ---------- */
247
+ // 收起时半吸附到屏幕左/右边缘,只露出一小截;桌面靠 hover、触摸靠首次点按
248
+ // 让整个按钮滑回屏幕内(滑入动画/可点区域由 music-player.js 内联 CSS 保证),
249
+ // 因此圆心即使部分在视口外,鼠标仍可在露出的半圆上 hover/点击。
250
+ function canPeek() {
251
+ return true
252
+ }
253
+ function snapToEdge() {
254
+ if (opened) return
255
+ var fabSize = fabEl.offsetWidth || 52
256
+ var rect = root.getBoundingClientRect()
257
+ var center = rect.left + fabSize / 2
258
+ var toLeft = center < window.innerWidth / 2
259
+ var top = clamp(rect.top, 0, Math.max(0, window.innerHeight - fabSize))
260
+ var left = toLeft ? 0 : window.innerWidth - fabSize
261
+ placeFab(left, top)
262
+ try {
263
+ localStorage.setItem(STORE_POS, JSON.stringify({ left: left, top: top }))
264
+ } catch (e) {
265
+ // ignore
266
+ }
267
+ fabEl.classList.toggle('is-peek-left', toLeft)
268
+ fabEl.classList.toggle('is-peek-right', !toLeft)
269
+ return toLeft
270
+ }
271
+
272
+ function revealFab() {
273
+ peekRevealed = true
274
+ fabEl.classList.add('is-revealed')
275
+ }
276
+
277
+ function peekFab() {
278
+ peekRevealed = false
279
+ fabEl.classList.remove('is-revealed')
280
+ }
281
+
282
+ function fabDragStart(e) {
283
+ if (opened) return // 展开状态下不让 FAB 拖(桌面拖面板标题栏;移动端面板固定)
284
+ if (e.pointerType === 'mouse' && e.button !== 0) return
285
+ // 触屏(或笔)首次点按仅全露(不展开),并吞掉这次 click;已全露后再点才展开。
286
+ // 鼠标悬停即全露,点击直接展开。
287
+ swallowPeekClick = e.pointerType !== 'mouse' && !peekRevealed
288
+ if (!peekRevealed) revealFab()
289
+ fabDrag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, moved: false }
290
+ try { fabEl.setPointerCapture(e.pointerId) } catch (err) {
291
+ // ignore
292
+ }
293
+ }
294
+
295
+ function fabDragMove(e) {
296
+ if (!fabDrag || e.pointerId !== fabDrag.id) return
297
+ if (!fabDrag.moved) {
298
+ if (Math.hypot(e.clientX - fabDrag.x0, e.clientY - fabDrag.y0) < DRAG_THRESHOLD) return
299
+ fabDrag.moved = true
300
+ suppressClick = true
301
+ dragging = true
302
+ fabEl.classList.add('is-dragging')
303
+ document.body.appendChild(overlay)
304
+ }
305
+ var rect = root.getBoundingClientRect()
306
+ placeFab(rect.left + (e.clientX - fabDrag.x0), rect.top + (e.clientY - fabDrag.y0))
307
+ fabDrag.x0 = e.clientX
308
+ fabDrag.y0 = e.clientY
309
+ }
310
+
311
+ function fabDragEnd(e) {
312
+ if (!fabDrag || (e && e.pointerId !== fabDrag.id)) return
313
+ if (fabDrag.moved) {
314
+ swallowPeekClick = false
315
+ snapToEdge()
316
+ setTimeout(function () {
317
+ suppressClick = false
318
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay)
319
+ }, 50)
320
+ }
321
+ fabEl.classList.remove('is-dragging')
322
+ dragging = false
323
+ fabDrag = null
324
+ }
325
+
326
+ var fabDrag = null
327
+ fabEl.addEventListener('pointerdown', fabDragStart)
328
+ fabEl.addEventListener('pointermove', fabDragMove)
329
+ fabEl.addEventListener('pointerup', fabDragEnd)
330
+ fabEl.addEventListener('pointercancel', fabDragEnd)
331
+
332
+ /* ---------- 展开 / 收起 ---------- */
333
+ function renderState() {
334
+ root.classList.toggle('is-mobile', isMobile())
335
+ root.classList.toggle('is-open', opened)
336
+ root.classList.toggle('is-collapsed', !opened)
337
+ toggleEl.textContent = opened ? '−' : '+'
338
+ }
339
+
340
+ var sizeRestored = false
341
+ function openDock() {
342
+ opened = true
343
+ peekRevealed = true
344
+ fabEl.classList.remove('is-peek-left', 'is-peek-right', 'is-revealed')
345
+ renderState()
346
+ loadDockCss()
347
+ .then(function () {
348
+ if (sizeRestored) return
349
+ sizeRestored = true
350
+ // 强制布局后再读取面板位置,避免 display:none 时 rect 为 0
351
+ void panelEl.offsetHeight
352
+ restoreSize()
353
+ })
354
+ .catch(function () {
355
+ sizeRestored = true
356
+ })
357
+ ensurePlayerMounted()
358
+ }
359
+
360
+ function closeDock() {
361
+ opened = false
362
+ renderState()
363
+ peekFab()
364
+ snapToEdge()
365
+ }
366
+
367
+ fabEl.addEventListener('click', function (e) {
368
+ e.stopPropagation()
369
+ if (suppressClick) return
370
+ if (swallowPeekClick) {
371
+ swallowPeekClick = false
372
+ return
373
+ }
374
+ openDock()
375
+ })
376
+
377
+ // 鼠标靠近全露出,离开恢复半吸附(展开态不处理)
378
+ fabEl.addEventListener('mouseenter', function () {
379
+ if (!opened) revealFab()
380
+ })
381
+ fabEl.addEventListener('mouseleave', function () {
382
+ if (!opened) peekFab()
383
+ })
384
+
385
+ // 触屏/鼠标点到按钮以外区域:恢复半吸附
386
+ document.addEventListener('pointerdown', function (e) {
387
+ if (opened || peekRevealed === false) return
388
+ if (root.contains(e.target)) return
389
+ peekFab()
390
+ })
391
+
392
+ toggleEl.addEventListener('click', function () {
393
+ if (suppressClick) return
394
+ closeDock()
395
+ })
396
+
397
+ if (mobileMQ.addEventListener) {
398
+ mobileMQ.addEventListener('change', renderState)
399
+ } else if (mobileMQ.addListener) {
400
+ mobileMQ.addListener(renderState)
401
+ }
402
+
403
+ /* ---------- 桌面端面板标题栏拖拽 ---------- */
404
+ handleEl.addEventListener('pointerdown', function (e) {
405
+ if (e.button !== undefined && e.button !== 0) return
406
+ if (isMobile()) return // 移动端面板固定底部
407
+ if (!opened) return
408
+ var rect = root.getBoundingClientRect()
409
+ pending = { x0: e.clientX, y0: e.clientY, dx: e.clientX - rect.left, dy: e.clientY - rect.top }
410
+ e.preventDefault()
411
+ })
412
+
413
+ document.addEventListener('pointermove', function (e) {
414
+ if (!pending) return
415
+ if (!dragging) {
416
+ var moved = Math.hypot(e.clientX - pending.x0, e.clientY - pending.y0)
417
+ if (moved < DRAG_THRESHOLD) return
418
+ dragging = true
419
+ suppressClick = true
420
+ document.body.appendChild(overlay)
421
+ }
422
+ // 直接移动 root(FAB 与绝对定位面板一起走)
423
+ placeFab(e.clientX - pending.dx, e.clientY - pending.dy)
424
+ })
425
+
426
+ document.addEventListener('pointerup', function () {
427
+ if (!pending) return
428
+ if (dragging) {
429
+ var rect = root.getBoundingClientRect()
430
+ try {
431
+ localStorage.setItem(STORE_POS, JSON.stringify({ left: rect.left, top: rect.top }))
432
+ } catch (e) {
433
+ // ignore
434
+ }
435
+ setTimeout(function () {
436
+ suppressClick = false
437
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay)
438
+ }, 50)
439
+ }
440
+ pending = null
441
+ dragging = false
442
+ })
443
+
444
+ /* ---------- 缩放(右下角拖柄,仅桌面端展开面板)
445
+ 关键:开始缩放时把面板的顶边钉在当前视口位置(top 锚定),
446
+ 因此向下拖也能长高,而不是被 bottom:64px 顶得只能向上长。 ---------- */
447
+ function anchorPanel(viewportTop) {
448
+ // panel 为相对 #bgm-dock 的绝对定位,需换算成相对其父容器的偏移
449
+ var rootRect = root.getBoundingClientRect()
450
+ panelEl.style.top = (viewportTop - rootRect.top) + 'px'
451
+ panelEl.style.bottom = 'auto'
452
+ }
453
+
454
+ function pinPanelTop() {
455
+ var rect = panelEl.getBoundingClientRect()
456
+ anchorPanel(clamp(rect.top, 0, Math.max(0, window.innerHeight - MIN_H)))
457
+ }
458
+
459
+ function unpinPanelTop() {
460
+ panelEl.style.top = ''
461
+ panelEl.style.bottom = ''
462
+ }
463
+
464
+ function applySize(w, h, persist) {
465
+ w = clamp(Math.round(w), MIN_W, Math.min(MAX_W, window.innerWidth - 16))
466
+ var curTop = panelEl.getBoundingClientRect().top
467
+ var maxH = Math.min(MAX_H, window.innerHeight - curTop - 8)
468
+ h = clamp(Math.round(h), MIN_H, Math.max(MIN_H, maxH))
469
+ panelEl.style.width = w + 'px'
470
+ panelEl.style.height = h + 'px'
471
+ root.classList.add('is-sized')
472
+ if (persist) {
473
+ try {
474
+ localStorage.setItem(STORE_SIZE, JSON.stringify({ w: w, h: h, top: Math.round(curTop) }))
475
+ } catch (e) {}
476
+ }
477
+ }
478
+
479
+ function resetSize() {
480
+ panelEl.style.width = ''
481
+ panelEl.style.height = ''
482
+ unpinPanelTop()
483
+ root.classList.remove('is-sized')
484
+ try { localStorage.removeItem(STORE_SIZE) } catch (e) {}
485
+ }
486
+
487
+ function restoreSize() {
488
+ if (isMobile()) return
489
+ try {
490
+ var s = JSON.parse(localStorage.getItem(STORE_SIZE) || 'null')
491
+ if (s && typeof s.w === 'number' && typeof s.h === 'number') {
492
+ var top = typeof s.top === 'number' ? s.top : panelEl.getBoundingClientRect().top
493
+ top = clamp(top, 0, Math.max(0, window.innerHeight - MIN_H))
494
+ anchorPanel(top)
495
+ applySize(s.w, s.h, false)
496
+ }
497
+ } catch (e) {}
498
+ }
499
+
500
+ var resizing = false
501
+ var rStart = null
502
+ var rMoved = false
503
+ var lastTap = 0
504
+ resizeEl.addEventListener('pointerdown', function (e) {
505
+ if (e.button !== undefined && e.button !== 0) return
506
+ if (isMobile()) return
507
+ resizing = true
508
+ rMoved = false
509
+ if (!panelEl.style.top) pinPanelTop()
510
+ rStart = { x: e.clientX, y: e.clientY, w: panelEl.offsetWidth, h: panelEl.offsetHeight }
511
+ document.body.appendChild(overlay)
512
+ overlay.style.cursor = 'nwse-resize'
513
+ e.preventDefault()
514
+ e.stopPropagation()
515
+ })
516
+
517
+ document.addEventListener('pointermove', function (e) {
518
+ if (!resizing) return
519
+ if (Math.abs(e.clientX - rStart.x) > 3 || Math.abs(e.clientY - rStart.y) > 3) rMoved = true
520
+ applySize(rStart.w + (e.clientX - rStart.x), rStart.h + (e.clientY - rStart.y), false)
521
+ })
522
+
523
+ document.addEventListener('pointerup', function () {
524
+ if (!resizing) return
525
+ resizing = false
526
+ overlay.style.cursor = ''
527
+ setTimeout(function () {
528
+ if (overlay.parentNode && !dragging) overlay.parentNode.removeChild(overlay)
529
+ }, 50)
530
+ if (rMoved) {
531
+ try {
532
+ var s = JSON.parse(localStorage.getItem(STORE_SIZE) || 'null') || {}
533
+ s.w = panelEl.offsetWidth
534
+ s.h = panelEl.offsetHeight
535
+ s.top = Math.round(panelEl.getBoundingClientRect().top)
536
+ localStorage.setItem(STORE_SIZE, JSON.stringify(s))
537
+ } catch (e) {}
538
+ } else {
539
+ var now = Date.now()
540
+ if (now - lastTap < 350) {
541
+ resetSize()
542
+ lastTap = 0
543
+ } else {
544
+ lastTap = now
545
+ }
546
+ }
547
+ })
548
+
549
+ var SPEEDS = [0.75, 1, 1.25, 1.5, 2]
550
+
551
+ function addSpeedControl(ap, holder) {
552
+ var time = holder.querySelector('.aplayer-time')
553
+ var audio = ap.audio
554
+ if (!time || !audio || time.querySelector('.bgm-speed')) return
555
+
556
+ var btn = document.createElement('span')
557
+ btn.className = 'aplayer-icon bgm-speed'
558
+ btn.title = '播放倍速'
559
+ btn.textContent = '1x'
560
+
561
+ var menu = document.createElement('div')
562
+ menu.className = 'bgm-speed__menu'
563
+ SPEEDS.forEach(function (rate) {
564
+ var item = document.createElement('button')
565
+ item.type = 'button'
566
+ item.className = 'bgm-speed__item'
567
+ item.dataset.rate = String(rate)
568
+ item.textContent = rate === 1 ? '正常' : rate + 'x'
569
+ item.addEventListener('click', function (e) {
570
+ e.stopPropagation()
571
+ audio.playbackRate = rate
572
+ btn.textContent = rate === 1 ? '1x' : rate + 'x'
573
+ menu.classList.remove('is-open')
574
+ menu.querySelectorAll('.bgm-speed__item').forEach(function (it) {
575
+ it.classList.toggle('is-active', Number(it.dataset.rate) === rate)
576
+ })
577
+ })
578
+ if (rate === 1) item.classList.add('is-active')
579
+ menu.appendChild(item)
580
+ })
581
+
582
+ btn.addEventListener('click', function (e) {
583
+ e.stopPropagation()
584
+ menu.classList.toggle('is-open')
585
+ })
586
+ document.addEventListener('click', function (e) {
587
+ if (!btn.contains(e.target) && !menu.contains(e.target)) menu.classList.remove('is-open')
588
+ })
589
+
590
+ time.insertBefore(menu, time.querySelector('.aplayer-volume-wrap'))
591
+ time.insertBefore(btn, menu)
592
+ }
593
+
594
+ /* ---------- 各平台播放器构建 ---------- */
595
+ function showLoading(holder) {
596
+ holder.classList.add('is-loading')
597
+ holder.textContent = '播放器加载中…'
598
+ }
599
+ function clearLoading(holder) {
600
+ holder.classList.remove('is-loading')
601
+ holder.textContent = ''
602
+ }
603
+
604
+ function attachAutoSkip(ap) {
605
+ if (!ap || ap.__ymAutoSkip) return
606
+ ap.__ymAutoSkip = true
607
+ var tried = 0
608
+ var skip = function () {
609
+ // 会员/版权歌曲网易云会 302 到 /404,APlayer 报 audio error 且不会自动跳。
610
+ // 这里在连续错误时自动切到下一首;遍历一遍仍全失败则停止,避免死循环。
611
+ var list = ap.list
612
+ var total = (list && list.audios && list.audios.length) || 0
613
+ tried += 1
614
+ if (!total || tried > total) return
615
+ try {
616
+ if (typeof ap.skip === 'function') ap.skip()
617
+ else if (list && typeof list.switch === 'function') list.switch((list.index || 0) + 1)
618
+ setTimeout(function () { try { ap.play() } catch (e) {} }, 250)
619
+ } catch (e) {
620
+ // ignore
621
+ }
622
+ }
623
+ try {
624
+ ap.on('error', skip)
625
+ // 兜底:部分 APlayer 版本 audio error 不冒泡到实例事件,直接监听媒体元素
626
+ var bindMedia = function () {
627
+ var audios = ap.container ? ap.container.querySelectorAll('audio') : []
628
+ Array.prototype.forEach.call(audios, function (au) {
629
+ if (au.__ymErrBound) return
630
+ au.__ymErrBound = true
631
+ au.addEventListener('error', function () {
632
+ // 真正无可用音源(404/会员限制)才跳,排除中断切换时的瞬时错误
633
+ var code = au.error && au.error.code
634
+ if (code === 4) skip()
635
+ })
636
+ })
637
+ }
638
+ ap.on('loadeddata', bindMedia)
639
+ setTimeout(bindMedia, 0)
640
+ setInterval(bindMedia, 1500)
641
+ } catch (e) {
642
+ // ignore
643
+ }
644
+ }
645
+
646
+ function buildLocal(src) {
647
+ var holder = document.createElement('div')
648
+ holder.className = 'bgm-dock__player bgm-dock__player--aplayer'
649
+ showLoading(holder)
650
+ bodyEl.appendChild(holder)
651
+
652
+ loadAPlayer()
653
+ .then(function () {
654
+ if (!holder.isConnected) return
655
+ clearLoading(holder)
656
+ // eslint-disable-next-line no-new, no-undef
657
+ var ap = new APlayer({
658
+ container: holder,
659
+ fixed: false,
660
+ theme: cfg.theme || '#0078e7',
661
+ autoplay: cfg.autoplay === true,
662
+ loop: 'all',
663
+ order: cfg.order === 'random' ? 'random' : 'list',
664
+ preload: 'auto',
665
+ volume: typeof cfg.volume === 'number' ? cfg.volume : 0.6,
666
+ listFolded: false,
667
+ audio: (src.songs || []).map(function (s) {
668
+ return { name: s.name, artist: s.artist, url: s.url, cover: s.cover }
669
+ }),
670
+ })
671
+ instances.push({ type: 'local', ap: ap, holder: holder })
672
+ addSpeedControl(ap, holder)
673
+ if (holder.classList.contains('is-active')) {
674
+ try { ap.play() } catch (e) {
675
+ // 自动播放可能被浏览器拦截
676
+ }
677
+ }
678
+ try {
679
+ ap.on('play', function () { root.classList.add('is-playing') })
680
+ ap.on('pause', function () { root.classList.remove('is-playing') })
681
+ } catch (e) {
682
+ // ignore
683
+ }
684
+ attachAutoSkip(ap)
685
+ })
686
+ .catch(function () {
687
+ holder.textContent = '播放器加载失败'
688
+ })
689
+
690
+ return holder
691
+ }
692
+
693
+ var metingLoading = null
694
+ function loadMeting() {
695
+ if (window.Meting || customElements.get('meting-js')) return Promise.resolve()
696
+ if (metingLoading) return metingLoading
697
+ metingLoading = loadAPlayer()
698
+ .then(function () { return loadScriptOnce(assetUrl('/js/lib/aplayer/Meting.min.js')) })
699
+ .then(function () {
700
+ if (!customElements.get('meting-js') && !window.Meting) throw new Error('meting missing')
701
+ })
702
+ .catch(function (e) {
703
+ metingLoading = null
704
+ throw e
705
+ })
706
+ return metingLoading
707
+ }
708
+
709
+ function buildMeting(src) {
710
+ var holder = document.createElement('div')
711
+ holder.className = 'bgm-dock__player bgm-dock__player--meting'
712
+ showLoading(holder)
713
+ bodyEl.appendChild(holder)
714
+
715
+ function createEl() {
716
+ if (holder.querySelector('meting-js')) return
717
+ clearLoading(holder)
718
+ var el = document.createElement('meting-js')
719
+ el.setAttribute('server', src.type === 'tencent' ? 'tencent' : 'netease')
720
+ el.setAttribute('type', src.resourceType || 'playlist')
721
+ el.setAttribute('id', src.id)
722
+ el.setAttribute('api', cfg.metingApi)
723
+ el.setAttribute('fixed', 'false')
724
+ el.setAttribute('theme', cfg.theme || '#0078e7')
725
+ el.setAttribute('autoplay', cfg.autoplay === true ? 'true' : 'false')
726
+ el.setAttribute('loop', 'all')
727
+ el.setAttribute('order', cfg.order === 'random' ? 'random' : 'list')
728
+ el.setAttribute('list-folded', 'false')
729
+ el.setAttribute('volume', String(typeof cfg.volume === 'number' ? cfg.volume : 0.6))
730
+ holder.appendChild(el)
731
+
732
+ setTimeout(function () {
733
+ var ap = holder.querySelector('.aplayer') && el.aplayer
734
+ if (ap) {
735
+ instances.push({ type: 'meting', ap: ap, holder: holder })
736
+ try {
737
+ ap.on('play', function () { root.classList.add('is-playing') })
738
+ ap.on('pause', function () { root.classList.remove('is-playing') })
739
+ } catch (e) {
740
+ // ignore
741
+ }
742
+ attachAutoSkip(ap)
743
+ }
744
+ }, 1500)
745
+ }
746
+
747
+ loadMeting().then(createEl).catch(function () {
748
+ holder.textContent = '播放器加载失败'
749
+ })
750
+ return holder
751
+ }
752
+
753
+ function buildSpotify(src) {
754
+ var holder = document.createElement('div')
755
+ holder.className = 'bgm-dock__player bgm-dock__player--spotify'
756
+ if (src.resourceType === 'song') holder.classList.add('is-song')
757
+ bodyEl.appendChild(holder)
758
+
759
+ var kind = src.resourceType === 'album' ? 'album' : src.resourceType === 'song' ? 'track' : 'playlist'
760
+ var iframe = document.createElement('iframe')
761
+ iframe.dataset.src = 'https://open.spotify.com/embed/' + kind + '/' + src.id
762
+ iframe.setAttribute('frameborder', '0')
763
+ iframe.setAttribute('allowtransparency', 'true')
764
+ iframe.setAttribute('allow', 'encrypted-media')
765
+ iframe.loading = 'lazy'
766
+ holder.appendChild(iframe)
767
+ return holder
768
+ }
769
+
770
+ function buildSource(src) {
771
+ if (src.type === 'local') return buildLocal(src)
772
+ if (src.type === 'spotify') return buildSpotify(src)
773
+ return buildMeting(src)
774
+ }
775
+
776
+ /* ---------- 源切换 ---------- */
777
+ function select(key) {
778
+ var src = sources.find(function (s) { return s.key === key })
779
+ if (!src) return
780
+
781
+ if (!built[key]) {
782
+ built[key] = buildSource(src)
783
+ }
784
+
785
+ Object.keys(built).forEach(function (k) {
786
+ built[k].classList.toggle('is-active', k === key)
787
+ })
788
+
789
+ instances.forEach(function (rec) {
790
+ if (!rec.holder.classList.contains('is-active') && rec.ap && typeof rec.ap.pause === 'function') {
791
+ try { rec.ap.pause() } catch (e) {
792
+ // ignore
793
+ }
794
+ }
795
+ })
796
+
797
+ bodyEl.querySelectorAll('.bgm-dock__player--spotify').forEach(function (holder) {
798
+ var iframe = holder.querySelector('iframe')
799
+ if (!iframe || !iframe.dataset.src) return
800
+ if (holder.classList.contains('is-active')) {
801
+ if (iframe.src !== iframe.dataset.src) iframe.src = iframe.dataset.src
802
+ } else if (iframe.src === iframe.dataset.src) {
803
+ iframe.src = 'about:blank'
804
+ }
805
+ })
806
+
807
+ tabsEl.querySelectorAll('.bgm-dock__tab').forEach(function (tab) {
808
+ tab.classList.toggle('is-active', tab.getAttribute('data-key') === key)
809
+ })
810
+
811
+ current = key
812
+ try {
813
+ localStorage.setItem(STORE_SRC, key)
814
+ } catch (e) {
815
+ // ignore
816
+ }
817
+ }
818
+
819
+ /* ---------- 标签 ---------- */
820
+ sources.forEach(function (src) {
821
+ var tab = document.createElement('button')
822
+ tab.type = 'button'
823
+ tab.className = 'bgm-dock__tab'
824
+ tab.setAttribute('data-key', src.key)
825
+ tab.textContent = src.name
826
+ tab.addEventListener('click', function () {
827
+ if (suppressClick) return
828
+ ensurePlayerMounted()
829
+ select(src.key)
830
+ })
831
+ tabsEl.appendChild(tab)
832
+ })
833
+
834
+ function hasNonSpotify() {
835
+ return sources.some(function (s) { return s.type !== 'spotify' })
836
+ }
837
+
838
+ /* ---------- 懒挂载:首次展开才创建播放器(双端一致) ---------- */
839
+ function ensurePlayerMounted() {
840
+ if (mounted) {
841
+ if (pendingKey) { var kk = pendingKey; pendingKey = null; select(kk) }
842
+ return
843
+ }
844
+ mounted = true
845
+ root.classList.add('is-mounted')
846
+ var k = pendingKey || initialSource.key
847
+ pendingKey = null
848
+ select(k)
849
+ }
850
+
851
+ /* ---------- 初始位置 / 初始源 ---------- */
852
+ applySavedPosition()
853
+ snapToEdge()
854
+
855
+ var savedKey = localStorage.getItem(STORE_SRC)
856
+ var initialSource =
857
+ sources.find(function (s) { return s.key === savedKey }) ||
858
+ sources.find(function (s) { return s.default }) ||
859
+ sources[0]
860
+
861
+ tabsEl.querySelectorAll('.bgm-dock__tab').forEach(function (tab) {
862
+ tab.classList.toggle('is-active', tab.getAttribute('data-key') === initialSource.key)
863
+ })
864
+ pendingKey = initialSource.key
865
+ current = initialSource.key
866
+
867
+ renderState()
868
+
869
+ window.addEventListener('resize', function () {
870
+ var rect = root.getBoundingClientRect()
871
+ placeFab(rect.left, rect.top)
872
+ if (!opened) snapToEdge()
873
+ })
874
+ })()
875
+
876
+