仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/common/player.js 18.3 KB · 596 行 原始文件
1/**
2 * 播放器(迁移自 lx-music-mobile/src/core/player/player.ts + plugins/player)
3 * 基于 uni.createInnerAudioContext
4 *
5 * 功能:
6 * - 4 种播放模式:列表循环 / 随机 / 顺序 / 单曲循环
7 * - 播放/暂停/上一曲/下一曲/进度跳转/音量
8 * - 播放列表管理
9 */
10import event from './event.js'
11import { getSetting, updateSetting } from './setting.js'
12import { getMusicUrl, getLyricInfo, getPicPath } from './music/index.js'
13import { PLAY_MODE_LIST } from './constants.js'
14import { getRandom } from './utils.js'
15import { addListMusics, LIST_IDS } from './playlist.js'
16
17let audio = null
18let playMusicInfo = null // 当前播放音乐信息
19let playList = [] // 当前播放列表
20let playIndex = -1
21let isPlay = false
22let currentTime = 0
23let duration = 0
24/** 播放结束处理重入锁,防止 onTimeUpdate 兜底在 getMusicUrl 异步期间反复触发 */
25let isHandlingEnded = false
26
27/**
28 * 播放地址改走本地 Go 代理(/audioproxy,见 pc/proxy.go)。
29 * CDN 无 CORS 头,直连时 WebAudio 会因跨源污染输出全零频谱;
30 * 经同源代理拉流后即可做真实音频可视化。
31 * playMusicInfo.url 仍保留原始 CDN 地址(下载、分享等场景不受影响)。
32 * 纯浏览器环境(wails dev 之外)没有代理,保持直连。
33 */
34let lastDirectUrl = '' // 最近一次要播放的原始 CDN 地址,代理失败回退直连用
35let proxyRetried = false // 当前曲目是否已尝试过直连回退(防循环)
36const toPlayUrl = (url) => {
37 if (!url || !/^https?:\/\//i.test(url)) return url
38 lastDirectUrl = url
39 proxyRetried = false
40 if (globalThis.uni && globalThis.uni.__native) {
41 return '/audioproxy?u=' + encodeURIComponent(url)
42 }
43 return url
44}
45
46/**
47 * 当前歌曲的歌词。
48 * 播放页是用户点击播放栏后才打开的,此时 playerLyricUpdated 事件早已广播完毕,
49 * 因此必须把歌词留存下来供页面 onLoad 时读取,否则会一直显示"暂无歌词"。
50 */
51let playLyricInfo = { lyric: '', tlyric: '' }
52
53/** 换源(如切换音质)后待跳转的播放进度;音频可播放后执行 seek */
54let pendingSeekTime = null
55
56// ============ 初始化 ============
57export const initPlayer = () => {
58 if (audio) return
59 audio = uni.createInnerAudioContext()
60 // App 端 obeyMuteSwitch 为只读属性,赋值会报错,仅 H5 可设置
61 try {
62 audio.obeyMuteSwitch = false
63 } catch (e) {}
64
65 // 原生回调作为乐观更新的兜底同步(幂等,值相同不重复广播)
66 audio.onPlay(() => {
67 setPlayState(true)
68 })
69 audio.onPause(() => {
70 // 切歌期间旧音频的 onPause 回调不应影响播放状态
71 if (isHandlingEnded) return
72 setPlayState(false)
73 })
74 audio.onStop(() => {
75 // 切歌期间旧音频的 onStop 回调不应影响播放状态
76 if (isHandlingEnded) return
77 setPlayState(false)
78 })
79 audio.onEnded(() => {
80 handleEnded()
81 })
82 // 换源后(切换音质等)恢复播放进度
83 audio.onCanplay(() => {
84 if (pendingSeekTime != null) {
85 audio.seek(pendingSeekTime)
86 pendingSeekTime = null
87 }
88 })
89 audio.onError((err) => {
90 console.error('[player error]', err)
91 // 代理播放失败:回退直连原始 CDN 地址重试一次(代理不可用时保底出声)。
92 // 直连后频谱会因跨源污染退回模拟模式,但播放不受影响。
93 if (
94 !proxyRetried &&
95 lastDirectUrl &&
96 audio.src &&
97 audio.src.indexOf('/audioproxy') === 0
98 ) {
99 proxyRetried = true
100 audio.src = lastDirectUrl
101 if (isPlay) safePlay()
102 return
103 }
104 event.emit('playerError', err)
105 // 出错自动下一曲
106 playNext(true)
107 })
108 audio.onTimeUpdate(() => {
109 currentTime = audio.currentTime
110 duration = audio.duration
111 event.emit('playerProgressChanged', { currentTime, duration })
112 // 兜底检测播放完成(部分 Android 机型 onEnded 不触发)
113 if (duration > 0 && currentTime >= duration - 0.5 && isPlay && !isHandlingEnded) {
114 handleEnded()
115 }
116 })
117
118 // 恢复上次播放信息
119 restorePlayInfo()
120}
121
122const restorePlayInfo = () => {
123 try {
124 const saved = uni.getStorageSync('starmusic_play_info')
125 if (saved && saved.musicInfo) {
126 playMusicInfo = { ...saved.musicInfo }
127 // 上次会话的播放地址是临时 CDN 链接,重启后基本已失效,
128 // 播放时会经 getMusicUrl 重新获取,这里直接丢弃避免误用
129 delete playMusicInfo.url
130 playList = saved.playList || []
131 playIndex = saved.playIndex || 0
132 currentTime = saved.time || 0
133 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
134 }
135 } catch (e) {
136 console.log(e)
137 }
138}
139
140let savePlayInfoTimer = null
141
142/**
143 * 持久化播放状态(防抖合并)
144 * playNext/playPrev/playCurrent 每次切歌都会触发,playList 可能很大,
145 * 连续切歌时频繁全量 setStorageSync 会阻塞 UI 线程,合并为一次写入。
146 */
147const savePlayInfo = () => {
148 if (savePlayInfoTimer) return
149 savePlayInfoTimer = setTimeout(() => {
150 savePlayInfoTimer = null
151 try {
152 uni.setStorageSync('starmusic_play_info', {
153 musicInfo: playMusicInfo,
154 playList,
155 playIndex,
156 time: currentTime,
157 })
158 } catch (e) {}
159 }, 500)
160}
161
162// ============ 对外接口 ============
163
164/**
165 * 播放列表(设置播放列表并播放指定索引)
166 * @param {Array} list 音乐列表
167 * @param {number} index 播放索引
168 */
169export const playListByList = (list, index) => {
170 if (!list || !list.length) return
171 playList = [...list]
172 playIndex = index
173 playMusicInfo = list[index]
174 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
175 playCurrent()
176}
177
178/**
179 * 播放单曲(加入当前列表或直接播放)
180 */
181export const playMusic = (musicInfo) => {
182 // 加入试听列表
183 const list = addListMusics(LIST_IDS.DEFAULT, [musicInfo], 'top')
184 playListByList(list, 0)
185}
186
187/** 播放当前歌曲(获取 URL 并播放) */
188const playCurrent = async () => {
189 if (!playMusicInfo) return
190 // 切歌时清掉上一首可能残留的换源 seek,避免作用于新歌
191 pendingSeekTime = null
192 // 点歌/切歌/自动下一曲都应当立即进入"播放中"状态(乐观更新)。
193 // URL 就绪后由 if (isPlay) safePlay() 真正开始播放;
194 // 若 URL 获取期间用户点了暂停(isPlay 被置 false),则不再自动播放。
195 setPlayState(true)
196 // 先清空上一首的歌词,避免新歌加载期间残留旧歌词
197 playLyricInfo = { lyric: '', tlyric: '' }
198 event.emit('playerLyricUpdated', playLyricInfo)
199 event.emit('playerGettingUrl')
200 try {
201 const info = await getMusicUrl({ musicInfo: playMusicInfo, isRefresh: false })
202 if (!info.url) throw new Error('获取播放地址失败')
203 playMusicInfo = { ...playMusicInfo, url: info.url }
204 audio.src = toPlayUrl(info.url)
205 // URL 获取期间用户可能已点击暂停(isPlay 被乐观置 false),此时不应自动播放
206 if (isPlay) safePlay()
207 savePlayInfo()
208
209 // 并行获取封面和歌词
210 getPicPath({ musicInfo: playMusicInfo })
211 .then((url) => {
212 playMusicInfo = { ...playMusicInfo, img: url || playMusicInfo.img }
213 event.emit('playerPicUpdated', url)
214 })
215 .catch(() => {})
216
217 // 记录本次请求对应的歌曲,避免快速切歌时旧请求的结果覆盖新歌歌词
218 const lyricTargetId = playMusicInfo.id
219 getLyricInfo({ musicInfo: playMusicInfo })
220 .then((lyricInfo) => {
221 if (!playMusicInfo || playMusicInfo.id !== lyricTargetId) return
222 playLyricInfo = lyricInfo
223 event.emit('playerLyricUpdated', lyricInfo)
224 })
225 .catch((err) => {
226 if (!playMusicInfo || playMusicInfo.id !== lyricTargetId) return
227 console.log('[lyric]', err.message)
228 playLyricInfo = { lyric: '', tlyric: '' }
229 event.emit('playerLyricUpdated', playLyricInfo)
230 })
231 } catch (err) {
232 console.error('[playCurrent]', err.message)
233 // 播放失败时恢复状态,避免按钮停留在"播放中"
234 setPlayState(false)
235 event.emit('playerError', err)
236 uni.showToast({ title: err.message || '播放失败', icon: 'none' })
237 } finally {
238 // 释放播放结束处理重入锁
239 isHandlingEnded = false
240 }
241}
242
243/**
244 * 刷新当前歌曲的播放地址(用于切换音质等场景)
245 * - 按指定音质重新获取 URL 并替换播放源
246 * - 保持当前播放/暂停状态不变;正在播放时从原进度续播
247 * - 失败时回滚为原状态,不影响继续播放
248 * @param {object} param0
249 * @param {string} [param0.quality] 目标音质(不传则按当前设置)
250 * @param {boolean} [param0.isRefresh] 是否强制刷新(绕过 URL 内存缓存)
251 */
252export const refreshPlayUrl = async ({ quality, isRefresh = true } = {}) => {
253 if (!audio) initPlayer()
254 if (!playMusicInfo) return false
255 // 记录目标歌曲与当前状态,供异步期间校验与恢复
256 const targetId = playMusicInfo.id
257 const wasPlaying = isPlay
258 const resumeTime = currentTime
259 const oldUrl = playMusicInfo.url
260 const oldState = playMusicInfo
261
262 try {
263 const info = await getMusicUrl({ musicInfo: playMusicInfo, quality, isRefresh })
264 if (!info.url) throw new Error('获取播放地址失败')
265 // 换源期间用户可能已切歌,此时放弃本次结果
266 if (!playMusicInfo || playMusicInfo.id !== targetId) return false
267
268 playMusicInfo = { ...playMusicInfo, url: info.url, quality: info.quality }
269 audio.src = toPlayUrl(info.url)
270 // 正在播放则续播:先 seek 到原进度(onCanplay 触发时执行)
271 if (wasPlaying) {
272 pendingSeekTime = resumeTime
273 safePlay()
274 }
275 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
276 savePlayInfo()
277 return true
278 } catch (err) {
279 console.error('[refreshPlayUrl]', err.message)
280 // 恢复原 URL,避免状态错乱
281 if (playMusicInfo && playMusicInfo.id === targetId) {
282 playMusicInfo = oldState
283 if (oldUrl) {
284 audio.src = toPlayUrl(oldUrl)
285 if (wasPlaying) safePlay()
286 }
287 }
288 event.emit('playerError', err)
289 throw err
290 }
291}
292
293/**
294 * 乐观更新播放状态
295 * App 端 InnerAudioContext 的 onPause/onPlay 回调可能不触发或延迟触发,
296 * 若只依赖回调更新 isPlay,会出现"已暂停但按钮不变"。
297 * 因此调用原生方法的同时立即更新状态并广播,原生回调到达时再做幂等同步。
298 */
299const setPlayState = (playing) => {
300 if (isPlay === playing) return
301 isPlay = playing
302 event.emit('playerStateChanged', { isPlay })
303}
304
305/** 播放/暂停切换 */
306export const togglePlay = () => {
307 if (!audio) initPlayer()
308 if (!playMusicInfo) return
309 if (isPlay) {
310 setPlayState(false)
311 audio.pause()
312 } else {
313 setPlayState(true)
314 if (!audio.src && playMusicInfo) {
315 playCurrent()
316 } else {
317 safePlay()
318 }
319 }
320}
321
322/** 暂停 */
323export const pause = () => {
324 if (audio && isPlay) {
325 setPlayState(false)
326 audio.pause()
327 }
328}
329
330/** 播放 */
331export const play = () => {
332 if (!audio) initPlayer()
333 if (!isPlay && playMusicInfo) {
334 setPlayState(true)
335 if (!audio.src) playCurrent()
336 else safePlay()
337 }
338}
339
340/** 安全的 audio.play()(H5 端返回 Promise,可能因自动播放策略被拒绝) */
341const safePlay = () => {
342 try {
343 const p = audio.play()
344 if (p && typeof p.catch === 'function') p.catch(() => {})
345 } catch (e) {
346 console.log('[safePlay]', e)
347 }
348}
349
350/** 停止 */
351export const stop = () => {
352 if (audio) {
353 audio.stop()
354 audio.src = ''
355 }
356 setPlayState(false)
357}
358
359/** 处理播放结束 */
360const handleEnded = () => {
361 if (isHandlingEnded) return
362 isHandlingEnded = true
363 const mode = getSetting()['player.togglePlayMethod']
364 if (mode === 'singleLoop') {
365 // 单曲循环
366 audio.seek(0)
367 safePlay()
368 isHandlingEnded = false
369 return
370 }
371 playNext(true)
372}
373
374/** 计算下一首索引 */
375const getNextIndex = (isAuto = true) => {
376 if (!playList.length) return -1
377 const mode = getSetting()['player.togglePlayMethod']
378 let index = playIndex
379 switch (mode) {
380 case 'listLoop': // 列表循环
381 index = playIndex === playList.length - 1 ? 0 : playIndex + 1
382 break
383 case 'random': // 随机
384 index = getRandom(0, playList.length)
385 break
386 case 'list': // 顺序播放
387 index = playIndex === playList.length - 1 ? (isAuto ? -1 : 0) : playIndex + 1
388 break
389 case 'singleLoop': // 单曲循环
390 index = playIndex
391 break
392 default:
393 index = playIndex === playList.length - 1 ? 0 : playIndex + 1
394 }
395 return index
396}
397
398/** 计算上一首索引 */
399const getPrevIndex = () => {
400 if (!playList.length) return -1
401 const mode = getSetting()['player.togglePlayMethod']
402 let index = playIndex
403 switch (mode) {
404 case 'random':
405 index = getRandom(0, playList.length)
406 break
407 default:
408 index = playIndex === 0 ? playList.length - 1 : playIndex - 1
409 }
410 return index
411}
412
413/** 下一曲 */
414export const playNext = (isAuto = false) => {
415 const index = getNextIndex(isAuto)
416 if (index < 0) {
417 if (isAuto) {
418 // 顺序播放结束
419 setPlayState(false)
420 }
421 return
422 }
423 playIndex = index
424 playMusicInfo = playList[index]
425 currentTime = 0
426 duration = 0
427 event.emit('playerProgressChanged', { currentTime: 0, duration: 0 })
428 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
429 playCurrent()
430}
431
432/** 上一曲 */
433export const playPrev = () => {
434 const index = getPrevIndex()
435 if (index < 0) return
436 playIndex = index
437 playMusicInfo = playList[index]
438 currentTime = 0
439 duration = 0
440 event.emit('playerProgressChanged', { currentTime: 0, duration: 0 })
441 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
442 playCurrent()
443}
444
445/** 切换播放模式 */
446export const togglePlayMethod = () => {
447 const setting = getSetting()
448 const current = setting['player.togglePlayMethod']
449 const currentIndex = PLAY_MODE_LIST.indexOf(current)
450 const next = PLAY_MODE_LIST[(currentIndex + 1) % PLAY_MODE_LIST.length]
451 updateSetting({ 'player.togglePlayMethod': next })
452 event.emit('playMethodChanged', next)
453 return next
454}
455
456/** 设置播放模式 */
457export const setPlayMethod = (mode) => {
458 updateSetting({ 'player.togglePlayMethod': mode })
459 event.emit('playMethodChanged', mode)
460}
461
462/** 跳转进度 */
463export const seek = (time) => {
464 if (audio) {
465 audio.seek(time)
466 currentTime = time
467 }
468}
469
470/** 设置音量 */
471export const setVolume = (volume) => {
472 if (audio) audio.volume = volume
473}
474
475/** 设置倍速 */
476export const setPlaybackRate = (rate) => {
477 if (audio) audio.playbackRate = rate
478}
479
480// ============ 状态获取 ============
481export const getPlayState = () => ({
482 isPlay,
483 currentTime,
484 duration,
485 playMusicInfo,
486 playList,
487 playIndex,
488 lyricInfo: playLyricInfo,
489})
490
491/** 获取当前歌词(供播放页初始化读取) */
492export const getLyric = () => playLyricInfo
493
494/**
495 * 重新获取当前歌曲歌词(跳过缓存)
496 * 用于播放页首次进入时歌词为空的补偿,以及用户手动刷新
497 */
498export const refreshLyric = async (isRefresh = false) => {
499 if (!playMusicInfo) return { lyric: '', tlyric: '' }
500 const targetId = playMusicInfo.id
501 try {
502 const lyricInfo = await getLyricInfo({ musicInfo: playMusicInfo, isRefresh })
503 if (!playMusicInfo || playMusicInfo.id !== targetId) return playLyricInfo
504 playLyricInfo = lyricInfo
505 event.emit('playerLyricUpdated', lyricInfo)
506 return lyricInfo
507 } catch (err) {
508 console.log('[refreshLyric]', err.message)
509 return playLyricInfo
510 }
511}
512
513export const getIsPlay = () => isPlay
514export const getCurrentMusic = () => playMusicInfo
515/**
516 * 获取底层 HTMLAudioElement(PC 端音频可视化用,
517 * InnerAudioContext 适配层把它存在 _el 字段上)
518 */
519export const getAudioElement = () => (audio && audio._el) || null
520export const getPlayList = () => [...playList]
521export const getPlayIndex = () => playIndex
522export const getCurrentTime = () => currentTime
523export const getDuration = () => duration
524
525// ============ 播放队列编辑(PC 版新增:桌面端需要直接管理队列) ============
526
527/**
528 * 移除队列中的某一项
529 * - 移除的不是当前曲目:仅调整索引,不打断正在播放的音频
530 * - 移除的是当前曲目:切到队列中的下一首(保持播放连续)
531 * @param {number} index
532 */
533export const removePlayListIndex = (index) => {
534 if (index < 0 || index >= playList.length) return
535 const wasCurrent = index === playIndex
536 const newList = playList.filter((_, i) => i !== index)
537
538 if (!newList.length) {
539 playList = []
540 playIndex = -1
541 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
542 savePlayInfo()
543 return
544 }
545
546 if (wasCurrent) {
547 // 保持「下一首」位置:原 index 处现在就是后继曲目
548 playList = newList
549 playIndex = index < newList.length ? index : 0
550 playMusicInfo = playList[playIndex]
551 currentTime = 0
552 duration = 0
553 event.emit('playerProgressChanged', { currentTime: 0, duration: 0 })
554 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
555 playCurrent()
556 return
557 }
558
559 playList = newList
560 if (index < playIndex) playIndex -= 1
561 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
562 savePlayInfo()
563}
564
565/**
566 * 清空队列(保留当前曲目继续播放)
567 */
568export const clearPlayListQueue = () => {
569 playList = playMusicInfo ? [playMusicInfo] : []
570 playIndex = playList.length ? 0 : -1
571 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
572 savePlayInfo()
573}
574
575/**
576 * 调整队列顺序(拖拽排序用)
577 * @param {number} from
578 * @param {number} to
579 */
580export const movePlayListIndex = (from, to) => {
581 if (from === to || from < 0 || to < 0 || from >= playList.length || to >= playList.length) return
582 const newList = [...playList]
583 const [moved] = newList.splice(from, 1)
584 newList.splice(to, 0, moved)
585 // 跟随当前曲目的新位置,避免拖拽后「当前播放」错位
586 const currentId = playMusicInfo ? playMusicInfo.id : null
587 playList = newList
588 playIndex = currentId ? newList.findIndex((m) => m.id === currentId) : playIndex
589 event.emit('playerMusicInfoChanged', { musicInfo: playMusicInfo, playList, playIndex })
590 savePlayInfo()
591}
592
593/** 从某列表按索引播放(供列表页使用) */
594export const playListIndex = (list, index) => {
595 playListByList(list, index)
596}