Compare commits

...

2 Commits

Author SHA1 Message Date
sqj
6ce05d286f feat(播放器): 重构全局播放管理逻辑并优化本地音乐匹配功能
新增全局播放管理器模块,集中处理播放控制逻辑
为本地音乐添加精准匹配功能及批量匹配进度显示
优化歌曲列表UI样式和操作体验
修复音频控制事件重复监听的问题
2025-11-23 23:12:36 +08:00
star
8209d021de update(local.vue): 更改界面布局 2025-11-15 16:07:19 +08:00
15 changed files with 945 additions and 792 deletions

View File

@@ -24,7 +24,7 @@ async function walkDir(dir: string, results: string[]) {
if (AUDIO_EXTS.has(ext)) results.push(full)
}
}
} catch { }
} catch {}
}
function readTags(filePath: string) {
@@ -41,7 +41,7 @@ function readTags(filePath: string) {
const buf = tag.pictures[0].data
const mime = tag.pictures[0].mimeType || 'image/jpeg'
img = `data:${mime};base64,${Buffer.from(buf).toString('base64')}`
} catch { }
} catch {}
}
let lrc: string | null = null
try {
@@ -49,7 +49,7 @@ function readTags(filePath: string) {
if (raw && typeof raw === 'string') {
lrc = normalizeLyricsToLrc(raw)
}
} catch { }
} catch {}
f.dispose()
return { title, album, performers, img, lrc }
} catch {
@@ -68,11 +68,17 @@ function normalizeLyricsToLrc(input: string): string {
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}]`
}
const out: string[] = []
for (let line of lines) {
if (!line.trim()) { out.push(line); continue }
for (const line of lines) {
if (!line.trim()) {
out.push(line)
continue
}
const off = /^\[offset:[+-]?\d+\]$/i.exec(line.trim())
if (off) { out.push(line.trim()); continue }
let mNew = /^\[(\d+),(\d+)\](.*)$/.exec(line)
if (off) {
out.push(line.trim())
continue
}
const mNew = /^\[(\d+),(\d+)\](.*)$/.exec(line)
if (mNew) {
const startMs = parseInt(mNew[1])
let text = mNew[3] || ''
@@ -86,7 +92,7 @@ function normalizeLyricsToLrc(input: string): string {
out.push(`${tag}${text}`)
continue
}
let mOld = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
const mOld = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
if (mOld) {
let text = mOld[2] || ''
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
@@ -104,69 +110,95 @@ function normalizeLyricsToLrc(input: string): string {
return out.join('\n')
}
function timeToMs(s: string): number {
const m = /(\d{2}):(\d{2})\.(\d{3})/.exec(s)
if (!m) return NaN
return parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + parseInt(m[3])
}
// function timeToMs(s: string): number {
// const m = /(\d{2}):(\d{2})\.(\d{3})/.exec(s)
// if (!m) return NaN
// return parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + parseInt(m[3])
// }
function normalizeLyricsToCrLyric(input: string): string {
const raw = String(input).replace(/\r/g, '')
const lines = raw.split('\n')
let offset = 0
const res: string[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!line.trim()) { res.push(line); continue }
const off = /^\[offset:([+-]?\d+)\]$/i.exec(line.trim())
if (off) { offset = parseInt(off[1]) || 0; res.push(line); continue }
const yrcLike = /\[\d+,\d+\]/.test(line) && /\(\d+,\d+,\d+\)/.test(line)
if (yrcLike) { res.push(line); continue }
const mLine = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
if (!mLine) { res.push(line); continue }
const lineStart = timeToMs(mLine[1]) + offset
let rest = mLine[2]
rest = rest.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
const segs: { start: number, text: string }[] = []
const re = /<(\d{2}:\d{2}\.\d{3})>([^<]*)/g
let m: RegExpExecArray | null
while ((m = re.exec(rest))) {
const start = timeToMs(m[1]) + offset
const text = m[2] || ''
if (text) segs.push({ start, text })
}
if (segs.length === 0) { res.push(line); continue }
let nextLineStart: number | null = null
for (let j = i + 1; j < lines.length; j++) {
const ml = /^\[(\d{2}:\d{2}\.\d{3})\]/.exec(lines[j])
if (ml) { nextLineStart = timeToMs(ml[1]) + offset; break }
const skip = lines[j].trim()
if (!skip || /^\[offset:/.test(skip)) continue
break
}
const tokens: string[] = []
for (let k = 0; k < segs.length; k++) {
const cur = segs[k]
const nextStart = k < segs.length - 1 ? segs[k + 1].start : (nextLineStart ?? (cur.start + 1000))
const span = Math.max(1, nextStart - cur.start)
const chars = Array.from(cur.text).filter((ch) => !/\s/.test(ch))
if (chars.length <= 1) {
if (chars.length === 1) tokens.push(`(${cur.start},${span},0)` + chars[0])
} else {
const per = Math.max(1, Math.floor(span / chars.length))
for (let c = 0; c < chars.length; c++) {
const cs = cur.start + c * per
const cd = c === chars.length - 1 ? Math.max(1, nextStart - cs) : per
tokens.push(`(${cs},${cd},0)` + chars[c])
}
}
}
const lineEnd = nextLineStart ?? (segs[segs.length - 1].start + Math.max(1, (nextLineStart ?? (segs[segs.length - 1].start + 1000)) - segs[segs.length - 1].start))
const ld = Math.max(0, lineEnd - lineStart)
res.push(`[${lineStart},${ld}]` + tokens.join(' '))
}
return res.join('\n')
}
// function normalizeLyricsToCrLyric(input: string): string {
// const raw = String(input).replace(/\r/g, '')
// const lines = raw.split('\n')
// let offset = 0
// const res: string[] = []
// for (let i = 0; i < lines.length; i++) {
// const line = lines[i]
// if (!line.trim()) {
// res.push(line)
// continue
// }
// const off = /^\[offset:([+-]?\d+)\]$/i.exec(line.trim())
// if (off) {
// offset = parseInt(off[1]) || 0
// res.push(line)
// continue
// }
// const yrcLike = /\[\d+,\d+\]/.test(line) && /\(\d+,\d+,\d+\)/.test(line)
// if (yrcLike) {
// res.push(line)
// continue
// }
// const mLine = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
// if (!mLine) {
// res.push(line)
// continue
// }
// const lineStart = timeToMs(mLine[1]) + offset
// let rest = mLine[2]
// rest = rest.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
// const segs: { start: number; text: string }[] = []
// const re = /<(\d{2}:\d{2}\.\d{3})>([^<]*)/g
// let m: RegExpExecArray | null
// while ((m = re.exec(rest))) {
// const start = timeToMs(m[1]) + offset
// const text = m[2] || ''
// if (text) segs.push({ start, text })
// }
// if (segs.length === 0) {
// res.push(line)
// continue
// }
// let nextLineStart: number | null = null
// for (let j = i + 1; j < lines.length; j++) {
// const ml = /^\[(\d{2}:\d{2}\.\d{3})\]/.exec(lines[j])
// if (ml) {
// nextLineStart = timeToMs(ml[1]) + offset
// break
// }
// const skip = lines[j].trim()
// if (!skip || /^\[offset:/.test(skip)) continue
// break
// }
// const tokens: string[] = []
// for (let k = 0; k < segs.length; k++) {
// const cur = segs[k]
// const nextStart =
// k < segs.length - 1 ? segs[k + 1].start : (nextLineStart ?? cur.start + 1000)
// const span = Math.max(1, nextStart - cur.start)
// const chars = Array.from(cur.text).filter((ch) => !/\s/.test(ch))
// if (chars.length <= 1) {
// if (chars.length === 1) tokens.push(`(${cur.start},${span},0)` + chars[0])
// } else {
// const per = Math.max(1, Math.floor(span / chars.length))
// for (let c = 0; c < chars.length; c++) {
// const cs = cur.start + c * per
// const cd = c === chars.length - 1 ? Math.max(1, nextStart - cs) : per
// tokens.push(`(${cs},${cd},0)` + chars[c])
// }
// }
// }
// const lineEnd =
// nextLineStart ??
// segs[segs.length - 1].start +
// Math.max(
// 1,
// (nextLineStart ?? segs[segs.length - 1].start + 1000) - segs[segs.length - 1].start
// )
// const ld = Math.max(0, lineEnd - lineStart)
// res.push(`[${lineStart},${ld}]` + tokens.join(' '))
// }
// return res.join('\n')
// }
ipcMain.handle('local-music:select-dirs', async () => {
const res = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
@@ -189,16 +221,25 @@ ipcMain.handle('local-music:scan', async (_e, dirs: string[]) => {
try {
for (const d of existsDirs) await walkDir(d, files)
const list = files.map((p) => {
let tags = { title: '', album: '', performers: [] as string[], img: '', lrc: null as null | string }
let tags = {
title: '',
album: '',
performers: [] as string[],
img: '',
lrc: null as null | string
}
try {
tags = readTags(p)
} catch { }
} catch {}
const base = path.basename(p)
const noExt = base.replace(path.extname(base), '')
let name = tags.title || ''
let singer = ''
if (!name) {
const segs = noExt.split(/[-_]|\s{2,}/).map((s) => s.trim()).filter(Boolean)
const segs = noExt
.split(/[-_]|\s{2,}/)
.map((s) => s.trim())
.filter(Boolean)
if (segs.length >= 2) {
singer = segs[0]
name = segs.slice(1).join(' ')
@@ -206,7 +247,8 @@ ipcMain.handle('local-music:scan', async (_e, dirs: string[]) => {
name = noExt
}
} else {
singer = Array.isArray(tags.performers) && tags.performers.length > 0 ? tags.performers[0] : ''
singer =
Array.isArray(tags.performers) && tags.performers.length > 0 ? tags.performers[0] : ''
}
const songmid = genId(p)
const item = {
@@ -261,16 +303,18 @@ ipcMain.handle('local-music:write-tags', async (_e, payload: any) => {
if (songInfo.img.startsWith('data:')) {
const m = songInfo.img.match(/^data:(.*?);base64,(.*)$/)
if (m) {
const mime = m[1]
// const mime = m[1]
const buf = Buffer.from(m[2], 'base64')
const tmp = path.join(path.dirname(filePath), genId(filePath) + '.cover')
await fsp.writeFile(tmp, buf)
const pic = taglib.Picture.fromPath(tmp)
songFile.tag.pictures = [pic]
try { await fsp.unlink(tmp) } catch { }
try {
await fsp.unlink(tmp)
} catch {}
}
}
} catch { }
} catch {}
}
songFile.save()
songFile.dispose()

View File

@@ -2,7 +2,7 @@ import path from 'node:path'
import fs from 'fs'
import fsp from 'fs/promises'
import crypto from 'crypto'
import { configManager } from './ConfigManager'
// import { configManager } from './ConfigManager'
export interface MusicItem {
hash?: string

View File

@@ -43,6 +43,7 @@ export default {
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
// }
// return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu`
// https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=1&templateVersion=2&tagId=1003449727
if (tagId == null) {
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1`
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
@@ -350,4 +351,4 @@ export default {
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -19,25 +19,49 @@ export default {
},
body: {
comm: {
ct: 11,
cv: '1003006',
v: '1003006',
ct: '11',
cv: '14090508',
v: '14090508',
tmeAppID: 'qqmusic',
phonetype: 'EBG-AN10',
deviceScore: '553.47',
devicelevel: '50',
newdevicelevel: '20',
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
os_ver: '12',
phonetype: '0',
devicelevel: '31',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI',
OpenUDID: '0',
OpenUDID2: '0',
QIMEI36: '0',
udid: '0',
chid: '0',
aid: '0',
oaid: '0',
taid: '0',
tid: '0',
wid: '0',
uid: '0',
sid: '0',
modeSwitch: '6',
teenMode: '0',
ui_mode: '2',
nettype: '1020',
v4ip: '',
},
req: {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicLite',
method: 'DoSearchForQQMusicMobile',
param: {
query: str,
search_type: 0,
num_per_page: limit,
query: str,
page_num: page,
num_per_page: limit,
highlight: 0,
nqc_flag: 0,
multi_zhida: 0,
cat: 2,
grp: 1,
sin: 0,
sem: 0,
},
},
},
@@ -159,4 +183,4 @@ export default {
})
})
},
}
}

View File

@@ -205,13 +205,11 @@ const api = {
return Array.isArray(res) ? res : []
},
writeTags: (filePath: string, songInfo: any, tagWriteOptions: any) =>
ipcRenderer.invoke('local-music:write-tags', { filePath, songInfo, tagWriteOptions })
,
ipcRenderer.invoke('local-music:write-tags', { filePath, songInfo, tagWriteOptions }),
getDirs: () => ipcRenderer.invoke('local-music:get-dirs'),
setDirs: (dirs: string[]) => ipcRenderer.invoke('local-music:set-dirs', dirs),
getList: () => ipcRenderer.invoke('local-music:get-list'),
getUrlById: (id: string | number) => ipcRenderer.invoke('local-music:get-url', id)
,
getUrlById: (id: string | number) => ipcRenderer.invoke('local-music:get-url', id),
clearIndex: () => ipcRenderer.invoke('local-music:clear-index')
},

View File

@@ -21,13 +21,11 @@ declare module 'vue' {
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']
NIcon: typeof import('naive-ui')['NIcon']
NInput: typeof import('naive-ui')['NInput']
NModal: typeof import('naive-ui')['NModal']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText']
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']

View File

@@ -35,6 +35,7 @@ onMounted(() => {
import('@renderer/utils/audio/globalControls')
.then((m) => m.installGlobalMusicControls())
.catch(() => {})
import('@renderer/utils/audio/globaPlayList').then((m) => m.initPlayback?.()).catch(() => {})
import('@renderer/utils/lyrics/desktopLyricBridge')
.then((m) => m.installDesktopLyricBridge())
.catch(() => {})
@@ -45,6 +46,7 @@ onMounted(() => {
}
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
window.electron?.ipcRenderer?.on?.('toggle', () => forward('toggle'))
window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev'))
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))

View File

@@ -1,12 +1,14 @@
<template>
<div class="song-virtual-list">
<!-- 表头 -->
<div class="list-header">
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
<div v-if="showDuration" class="col-duration">时长</div>
<div class="list-header-container" style="background-color: var(--song-list-header-bg)">
<div class="list-header" :style="{ marginRight: hasScroll ? '10px' : '0' }">
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
<div v-if="showDuration" class="col-duration">时长</div>
</div>
</div>
<!-- 虚拟滚动容器 -->
@@ -201,6 +203,13 @@ const contextMenuSong = ref<Song | null>(null)
// 歌单列表
const playlists = ref<SongList[]>([])
const hasScroll = computed(() => {
// 判断是否有滚动条
return !!(
scrollContainer.value && scrollContainer.value.scrollHeight > scrollContainer.value.clientHeight
)
})
// 计算总高度
const totalHeight = computed(() => props.songs.length * itemHeight)
@@ -564,8 +573,8 @@ onUnmounted(() => {
.list-header {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
grid-template-columns: 50px 1fr 200px 60px 80px;
padding: 8px 10px;
background: var(--song-list-header-bg);
border-bottom: 1px solid var(--song-list-header-border);
font-size: 12px;
@@ -583,7 +592,7 @@ onUnmounted(() => {
}
.col-title {
padding-left: 20px;
padding-left: 10px;
display: flex;
align-items: center;
}
@@ -630,8 +639,8 @@ onUnmounted(() => {
.song-item {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
grid-template-columns: 50px 1fr 200px 60px 80px;
padding: 8px 10px;
border-bottom: 1px solid var(--song-list-item-border);
cursor: pointer;
transition: background-color 0.2s ease;
@@ -664,7 +673,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
width: 100%;
.track-number {
font-size: 14px;

View File

@@ -8,8 +8,7 @@ import {
nextTick,
onActivated,
onDeactivated,
toRaw,
provide
toRaw
} from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
@@ -23,11 +22,17 @@ import { getBestContrastTextColorWithOpacity } from '@renderer/utils/color/contr
import { PlayMode, type SongList } from '@renderer/types/audio'
import { MessagePlugin } from 'tdesign-vue-next'
import {
initPlaylistEventListeners,
destroyPlaylistEventListeners,
getSongRealUrl
} from '@renderer/utils/playlist/playlistManager'
import mediaSessionController from '@renderer/utils/audio/useSmtc'
songInfo,
playNext,
playPrevious,
updatePlayMode,
togglePlayPause,
isLoadingSong,
setVolume,
seekTo,
playSong,
playMode
} from '@renderer/utils/audio/globaPlayList'
import defaultCoverImg from '/default-cover.png'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next'
@@ -38,7 +43,7 @@ const controlAudio = ControlAudioStore()
const localUserStore = LocalUserDetailStore()
const { Audio } = storeToRefs(controlAudio)
const { list, userInfo } = storeToRefs(localUserStore)
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
const {} = controlAudio
// 当前歌曲是否已在“我的喜欢”
const likeState = ref(false)
@@ -104,51 +109,7 @@ const toggleDesktopLyric = async () => {
}
}
// 等待音频准备就绪
const waitForAudioReady = (): Promise<void> => {
return new Promise((resolve, reject) => {
const audio = Audio.value.audio
if (!audio) {
reject(new Error('音频元素未初始化'))
return
}
// 如果音频已经准备就绪
if (audio.readyState >= 3) {
// HAVE_FUTURE_DATA
resolve()
return
}
// 设置超时
const timeout = setTimeout(() => {
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载超时'))
}, 10000) // 10秒超时
const onCanPlay = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
resolve()
}
const onError = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载失败'))
}
// 监听事件
audio.addEventListener('canplay', onCanPlay, { once: true })
audio.addEventListener('error', onError, { once: true })
})
}
// 存储待恢复的播放位置
let pendingRestorePosition = 0
let pendingRestoreSongId: number | string | null = null
// 播放位置恢复逻辑由全局播放管理器处理
// 记录组件被停用前的播放状态
// let wasPlaying = false
@@ -156,174 +117,6 @@ let pendingRestoreSongId: number | string | null = null
// let playbackPosition = 0
let isFull = false
// 播放指定歌曲
const playSong = async (song: SongList) => {
try {
// 设置加载状态
isLoadingSong.value = true
// 检查是否需要恢复播放位置(历史播放)
const isHistoryPlay =
song.songmid === userInfo.value.lastPlaySongId &&
userInfo.value.currentTime !== undefined &&
userInfo.value.currentTime > 0
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = song.songmid
console.log(`准备恢复播放位置: ${pendingRestorePosition}`)
// 清除历史位置,避免重复恢复
userInfo.value.currentTime = 0
} else {
pendingRestorePosition = 0
pendingRestoreSongId = null
}
// 立刻暂停当前播放 - 不等待渐变
if (Audio.value.isPlay && Audio.value.audio) {
Audio.value.isPlay = false
Audio.value.audio.pause()
// 恢复音量避免下次播放音量为0
Audio.value.audio.volume = Audio.value.volume / 100
}
// 立刻更新 UI 到新歌曲
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
userInfo.value.lastPlaySongId = song.songmid
// 如果播放列表是打开的,滚动到当前播放歌曲
if (showPlaylist.value) {
nextTick(() => {
playlistDrawerRef.value?.scrollToCurrentSong()
})
}
// 更新媒体会话元数据
mediaSessionController.updateMetadata({
title: song.name,
artist: song.singer,
album: song.albumName || '未知专辑',
artworkUrl: song.img || defaultCoverImg
})
// 尝试获取 URL
let urlToPlay = ''
try {
urlToPlay = await getSongRealUrl(toRaw(song))
} catch (error: any) {
console.error('获取歌曲 URL 失败,播放下一首原歌曲:', error)
isLoadingSong.value = false
tryAutoNext('获取歌曲 URL 失败')
return
}
// 在切换前彻底重置旧音频,释放缓冲与解码器
if (Audio.value.audio) {
const a = Audio.value.audio
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
}
// 设置 URL(这会触发音频重新加载)
setUrl(urlToPlay)
// 等待音频准备就绪
await waitForAudioReady()
await setColor()
// 更新完整歌曲信息
songInfo.value = { ...song }
/**
* 提前关闭加载状态
* 这样UI不会卡在“加载中”用户能立刻看到播放键切换
*/
isLoadingSong.value = false
/**
* 异步开始播放不await以免阻塞UI
*/
start()
.catch(async (error: any) => {
console.error('启动播放失败:', error)
tryAutoNext('启动播放失败')
})
.then(() => {
autoNextCount.value = 0
})
/**
* 注册事件监听确保浏览器播放事件触发时同步关闭loading
* (多一道保险)
*/
if (Audio.value.audio) {
Audio.value.audio.addEventListener(
'playing',
() => {
isLoadingSong.value = false
},
{ once: true }
)
Audio.value.audio.addEventListener(
'error',
() => {
isLoadingSong.value = false
},
{ once: true }
)
}
} catch (error: any) {
console.error('播放歌曲失败(外层捕获):', error)
tryAutoNext('播放歌曲失败')
// MessagePlugin.error('播放失败,原因:' + error.message)
isLoadingSong.value = false
} finally {
// 最后的保险,确保加载状态一定会被关闭
isLoadingSong.value = false
}
}
provide('PlaySong', playSong)
// 歌曲信息
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
// const playMode = ref(PlayMode.SEQUENCE)
// 歌曲加载状态
const isLoadingSong = ref(false)
// 自动下一首次数限制不超过当前列表的30%
const autoNextCount = ref(0)
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
const tryAutoNext = (reason: string) => {
const limit = getAutoNextLimit()
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
MessagePlugin.error(
`自动下一首失败超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
)
return
}
autoNextCount.value++
playNext()
}
// 更新播放模式
const updatePlayMode = () => {
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
const currentIndex = modes.indexOf(playMode.value)
const nextIndex = (currentIndex + 1) % modes.length
playMode.value = modes[nextIndex]
// 更新用户信息
userInfo.value.playMode = playMode.value
}
// 获取播放模式图标类名
let playModeTip = ''
const playModeIconClass = computed(() => {
@@ -426,161 +219,12 @@ const closePlaylist = () => {
}
// 播放上一首
const playPrevious = async () => {
if (list.value.length === 0) return
try {
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
let prevIndex
if (playMode.value === PlayMode.RANDOM) {
// 随机模式
prevIndex = Math.floor(Math.random() * list.value.length)
} else {
// 顺序模式或单曲循环模式
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
}
// 确保索引有效
if (prevIndex >= 0 && prevIndex < list.value.length) {
await playSong(list.value[prevIndex])
}
} catch (error) {
console.error('播放上一首失败:', error)
MessagePlugin.error('播放上一首失败')
}
}
// 播放下一首
const playNext = async () => {
if (list.value.length === 0) return
try {
// 单曲循环模式下,重新播放当前歌曲
if (playMode.value === PlayMode.SINGLE && currentSongId.value) {
const currentSong = list.value.find((song) => song.songmid === currentSongId.value)
if (currentSong) {
// 重新设置播放位置到开头
if (Audio.value.audio) {
Audio.value.audio.currentTime = 0
}
// 如果当前正在播放,继续播放;如果暂停,保持暂停
const startResult = start()
if (startResult && typeof startResult.then === 'function') {
await startResult
}
return
}
}
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
let nextIndex
if (playMode.value === PlayMode.RANDOM) {
// 随机模式
nextIndex = Math.floor(Math.random() * list.value.length)
} else {
// 顺序模式
nextIndex = (currentIndex + 1) % list.value.length
}
// 确保索引有效
if (nextIndex >= 0 && nextIndex < list.value.length) {
await playSong(list.value[nextIndex])
}
} catch (error) {
console.error('播放下一首失败:', error)
MessagePlugin.error('播放下一首失败')
}
}
// 上一首/下一首由全局播放管理器提供
// 定期保存当前播放位置
let savePositionInterval: number | null = null
const PlayerEvent = (e: any) => {
const name = e?.detail?.name
console.log(name)
switch (name) {
case 'play':
handlePlay()
break
case 'pause':
handlePause()
break
case 'toggle':
togglePlayPause()
break
case 'playPrev':
playPrevious()
break
case 'playNext':
playNext()
break
}
}
// 全局快捷控制事件由全局播放管理器处理
// 初始化播放器
onMounted(async () => {
console.log('加载')
// 初始化播放列表事件监听器
initPlaylistEventListeners(localUserStore, playSong)
// 检查是否有上次播放的歌曲
// 检查是否有上次播放的歌曲
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
if (lastPlayedSong) {
songInfo.value = {
...lastPlayedSong
}
// 立即更新媒体会话元数据,让系统显示当前歌曲信息
mediaSessionController.updateMetadata({
title: lastPlayedSong.name,
artist: lastPlayedSong.singer,
album: lastPlayedSong.albumName || '未知专辑',
artworkUrl: lastPlayedSong.img || defaultCoverImg
})
// 如果有历史播放位置,设置为待恢复状态
if (!Audio.value.isPlay) {
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = lastPlayedSong.songmid
console.log(`初始化时设置待恢复位置: ${pendingRestorePosition}`)
// 设置当前播放时间以显示进度条位置,但不清除历史记录
if (Audio.value.audio) {
Audio.value.audio.currentTime = userInfo.value.currentTime
}
}
// 通过工具函数获取歌曲URL
try {
const url = await getSongRealUrl(toRaw(lastPlayedSong))
setUrl(url)
} catch (error) {
console.error('获取上次播放歌曲URL失败:', error)
}
} else {
// 同步实际播放状态,避免误写为 playing
if (Audio.value.audio) {
mediaSessionController.updatePlaybackState(
Audio.value.audio.paused ? 'paused' : 'playing'
)
}
}
}
}
// 定期保存当前播放位置
savePositionInterval = window.setInterval(() => {
if (Audio.value.isPlay) {
userInfo.value.currentTime = Audio.value.currentTime
}
}, 1000) // 每1秒保存一次
// 监听播放器事件
// TODO: 这边监听没有取消
// 监听来自主进程的锁定状态广播
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
desktopLyricLocked.value = !!lock
@@ -590,20 +234,12 @@ onMounted(async () => {
desktopLyricOpen.value = false
desktopLyricLocked.value = false
})
window.addEventListener('global-music-control', PlayerEvent)
})
// 组件卸载时清理
onUnmounted(() => {
destroyPlaylistEventListeners()
// document.removeEventListener('keydown', KeyEvent)
window.removeEventListener('global-music-control', PlayerEvent)
window.electron?.ipcRenderer?.removeAllListeners?.('toogleDesktopLyricLock')
window.electron?.ipcRenderer?.removeAllListeners?.('closeDesktopLyric')
if (savePositionInterval !== null) {
clearInterval(savePositionInterval)
}
})
// 组件被激活时(从缓存中恢复)
@@ -612,25 +248,6 @@ onActivated(async () => {
if (isFull) {
showFullPlay.value = true
}
// 如果之前正在播放,恢复播放
// if (wasPlaying && Audio.value.url) {
// // 恢复播放位置
// if (Audio.value.audio && playbackPosition > 0) {
// setCurrentTime(playbackPosition)
// Audio.value.audio.currentTime = playbackPosition
// }
// // 恢复播放
// try {
// const startResult = start()
// if (startResult && typeof startResult.then === 'function') {
// await startResult
// }
// console.log('恢复播放成功')
// } catch (error) {
// console.error('恢复播放失败:', error)
// }
// }
})
// 组件被停用时(缓存但不销毁)
@@ -761,75 +378,6 @@ const formatTime = (seconds: number) => {
const currentTimeFormatted = computed(() => formatTime(Audio.value.currentTime))
const durationFormatted = computed(() => formatTime(Audio.value.duration))
// 专门的播放函数
const handlePlay = async () => {
if (!Audio.value.url) {
// 如果没有URL但有播放列表尝试播放第一首歌
if (list.value.length > 0) {
await playSong(list.value[0])
} else {
MessagePlugin.warning('播放列表为空,请先添加歌曲')
}
return
}
try {
// 检查是否需要恢复历史播放位置
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
console.log(`恢复播放位置: ${pendingRestorePosition}`)
// 等待音频准备就绪
await waitForAudioReady()
// 设置播放位置
setCurrentTime(pendingRestorePosition)
if (Audio.value.audio) {
Audio.value.audio.currentTime = pendingRestorePosition
}
// 清除待恢复的位置
pendingRestorePosition = 0
pendingRestoreSongId = null
}
const startResult = start()
if (startResult && typeof startResult.then === 'function') {
await startResult
}
// 播放已开始后,同步 SMTC 状态
mediaSessionController.updatePlaybackState('playing')
} catch (error) {
console.error('播放失败:', error)
MessagePlugin.error('播放失败,请重试')
}
}
// 专门的暂停函数
const handlePause = async () => {
const a = Audio.value.audio
if (Audio.value.url && a && !a.paused) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
mediaSessionController.updatePlaybackState('paused')
} else if (Audio.value.url) {
// 已处于暂停或未知状态,也同步一次 SMTC确保外部显示一致
mediaSessionController.updatePlaybackState('paused')
}
}
// 播放/暂停切换
const togglePlayPause = async () => {
const a = Audio.value.audio
const isActuallyPlaying = a ? !a.paused : Audio.value.isPlay
if (isActuallyPlaying) {
await handlePause()
} else {
await handlePlay()
}
}
// 进度条拖动处理
const handleProgressClick = (event: MouseEvent) => {
if (!progressRef.value) return
@@ -842,11 +390,7 @@ const handleProgressClick = (event: MouseEvent) => {
tempProgressPercentage.value = percentage
const newTime = (percentage / 100) * Audio.value.duration
setCurrentTime(newTime)
if (Audio.value.audio) {
Audio.value.audio.currentTime = newTime
}
seekTo(newTime)
}
const handleProgressDragMove = (event: MouseEvent) => {
@@ -873,11 +417,7 @@ const handleProgressDragEnd = (event: MouseEvent) => {
const offsetX = Math.max(0, Math.min(event.clientX - rect.left, rect.width))
const percentage = (offsetX / rect.width) * 100
const newTime = (percentage / 100) * Audio.value.duration
setCurrentTime(newTime)
if (Audio.value.audio) {
Audio.value.audio.currentTime = newTime
}
seekTo(newTime)
isDraggingProgress.value = false
window.removeEventListener('mousemove', handleProgressDragMove)
@@ -893,22 +433,7 @@ const handleProgressDragStart = (event: MouseEvent) => {
window.addEventListener('mouseup', handleProgressDragEnd)
}
// 歌曲信息
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
songmid: null,
hash: '',
name: '欢迎使用CeruMusic 🎉',
singer: '可以配置音源插件来播放你的歌曲',
albumName: '',
albumId: '0',
source: '',
interval: '00:00',
img: '',
lrc: null,
types: [],
_types: {},
typeUrl: {}
})
// 歌曲信息由全局播放管理器提供
const maincolor = ref('var(--td-brand-color-5)')
const startmaincolor = ref('rgba(0, 0, 0, 1)')
const contrastTextColor = ref('rgba(0, 0, 0, .8)')

View File

@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
currentTime: 0,
duration: 0,
volume: 80,
url: ''
url: '',
eventInit: false
})
// -------------------------------------------发布订阅逻辑------------------------------------------

View File

@@ -44,6 +44,7 @@ export type ControlAudioState = {
duration: number
volume: number
url: string
eventInit: boolean
}
export type SongList = playList

View File

@@ -1,4 +1,5 @@
// 全局音频管理器,用于管理音频源和分析器
class AudioManager {
private static instance: AudioManager
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()

View File

@@ -0,0 +1,407 @@
import { ref, toRaw } from 'vue'
import { storeToRefs } from 'pinia'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { PlayMode, type SongList } from '@renderer/types/audio'
import { MessagePlugin } from 'tdesign-vue-next'
import defaultCoverImg from '/default-cover.png'
import mediaSessionController from '@renderer/utils/audio/useSmtc'
import {
getSongRealUrl,
initPlaylistEventListeners,
destroyPlaylistEventListeners
} from '@renderer/utils/playlist/playlistManager'
const controlAudio = ControlAudioStore()
const localUserStore = LocalUserDetailStore()
const { Audio } = storeToRefs(controlAudio)
const { list, userInfo } = storeToRefs(localUserStore)
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
songmid: null,
hash: '',
name: '欢迎使用CeruMusic 🎉',
singer: '可以配置音源插件来播放你的歌曲',
albumName: '',
albumId: '0',
source: '',
interval: '00:00',
img: '',
lrc: null,
types: [],
_types: {},
typeUrl: {}
})
let pendingRestorePosition = 0
let pendingRestoreSongId: number | string | null = null
const waitForAudioReady = (): Promise<void> => {
return new Promise((resolve, reject) => {
const audio = Audio.value.audio
if (!audio) {
reject(new Error('音频元素未初始化'))
return
}
if (audio.readyState >= 3) {
resolve()
return
}
const timeout = setTimeout(() => {
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载超时'))
}, 10000)
const onCanPlay = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
resolve()
}
const onError = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载失败'))
}
audio.addEventListener('canplay', onCanPlay, { once: true })
audio.addEventListener('error', onError, { once: true })
})
}
const setUrl = controlAudio.setUrl
const start = controlAudio.start
const stop = controlAudio.stop
const setCurrentTime = controlAudio.setCurrentTime
const handlePlay = async () => {
if (!Audio.value.url) {
if (list.value.length > 0) {
await playSong(list.value[0])
} else {
MessagePlugin.warning('播放列表为空,请先添加歌曲')
}
return
}
try {
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
await waitForAudioReady()
setCurrentTime(pendingRestorePosition)
if (Audio.value.audio) {
Audio.value.audio.currentTime = pendingRestorePosition
}
pendingRestorePosition = 0
pendingRestoreSongId = null
}
const startResult = start()
if (startResult && typeof (startResult as any).then === 'function') {
await startResult
}
mediaSessionController.updatePlaybackState('playing')
} catch (error) {
MessagePlugin.error('播放失败,请重试')
}
}
const handlePause = async () => {
const a = Audio.value.audio
if (Audio.value.url && a && !a.paused) {
const stopResult = stop()
if (stopResult && typeof (stopResult as any).then === 'function') {
await stopResult
}
mediaSessionController.updatePlaybackState('paused')
} else if (Audio.value.url) {
mediaSessionController.updatePlaybackState('paused')
}
}
const togglePlayPause = async () => {
const a = Audio.value.audio
const isActuallyPlaying = a ? !a.paused : Audio.value.isPlay
if (isActuallyPlaying) {
await handlePause()
} else {
await handlePlay()
}
}
const playSong = async (song: SongList) => {
try {
isLoadingSong.value = true
const isHistoryPlay =
song.songmid === userInfo.value.lastPlaySongId &&
userInfo.value.currentTime !== undefined &&
userInfo.value.currentTime > 0
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = song.songmid
userInfo.value.currentTime = 0
} else {
pendingRestorePosition = 0
pendingRestoreSongId = null
}
if (Audio.value.isPlay && Audio.value.audio) {
Audio.value.isPlay = false
Audio.value.audio.pause()
Audio.value.audio.volume = Audio.value.volume / 100
}
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
userInfo.value.lastPlaySongId = song.songmid
mediaSessionController.updateMetadata({
title: song.name,
artist: song.singer,
album: song.albumName || '未知专辑',
artworkUrl: song.img || defaultCoverImg
})
let urlToPlay = ''
try {
urlToPlay = await getSongRealUrl(toRaw(song))
} catch (error: any) {
isLoadingSong.value = false
tryAutoNext('获取歌曲 URL 失败')
return
}
if (Audio.value.audio) {
const a = Audio.value.audio
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
}
setUrl(urlToPlay)
await waitForAudioReady()
songInfo.value = { ...song }
isLoadingSong.value = false
start()
.catch(async () => {
tryAutoNext('启动播放失败')
})
.then(() => {
autoNextCount.value = 0
})
if (Audio.value.audio) {
Audio.value.audio.addEventListener(
'playing',
() => {
isLoadingSong.value = false
},
{ once: true }
)
Audio.value.audio.addEventListener(
'error',
() => {
isLoadingSong.value = false
},
{ once: true }
)
}
} catch (error: any) {
tryAutoNext('播放歌曲失败')
isLoadingSong.value = false
} finally {
isLoadingSong.value = false
}
}
const tryAutoNext = (reason: string) => {
const limit = getAutoNextLimit()
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
MessagePlugin.error(
`自动下一首失败超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
)
return
}
autoNextCount.value++
playNext()
}
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
const isLoadingSong = ref(false)
const autoNextCount = ref(0)
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
const updatePlayMode = () => {
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
const currentIndex = modes.indexOf(playMode.value)
const nextIndex = (currentIndex + 1) % modes.length
playMode.value = modes[nextIndex]
userInfo.value.playMode = playMode.value
}
const playPrevious = async () => {
if (list.value.length === 0) return
try {
const currentIndex = list.value.findIndex(
(song) => song.songmid === userInfo.value.lastPlaySongId
)
let prevIndex
if (playMode.value === PlayMode.RANDOM) {
prevIndex = Math.floor(Math.random() * list.value.length)
} else {
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
}
if (prevIndex >= 0 && prevIndex < list.value.length) {
await playSong(list.value[prevIndex])
}
} catch {
MessagePlugin.error('播放上一首失败')
}
}
const playNext = async () => {
if (list.value.length === 0) return
try {
if (playMode.value === PlayMode.SINGLE && userInfo.value.lastPlaySongId) {
const currentSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
if (currentSong) {
if (Audio.value.audio) {
Audio.value.audio.currentTime = 0
}
const startResult = start()
if (startResult && typeof (startResult as any).then === 'function') {
await startResult
}
return
}
}
const currentIndex = list.value.findIndex(
(song) => song.songmid === userInfo.value.lastPlaySongId
)
let nextIndex
if (playMode.value === PlayMode.RANDOM) {
nextIndex = Math.floor(Math.random() * list.value.length)
} else {
nextIndex = (currentIndex + 1) % list.value.length
}
if (nextIndex >= 0 && nextIndex < list.value.length) {
await playSong(list.value[nextIndex])
}
} catch {
MessagePlugin.error('播放下一首失败')
}
}
const setVolume = (v: number) => controlAudio.setVolume(v)
const seekTo = (time: number) => {
setCurrentTime(time)
if (Audio.value.audio) {
Audio.value.audio.currentTime = time
}
}
let savePositionInterval: number | null = null
const onGlobalCtrl = (e: any) => {
const name = e?.detail?.name
const val = e?.detail?.val
switch (name) {
case 'play':
void handlePlay()
break
case 'pause':
void handlePause()
break
case 'toggle':
void togglePlayPause()
break
case 'playPrev':
void playPrevious()
break
case 'playNext':
void playNext()
break
case 'volumeDelta':
{
const next = Math.max(0, Math.min(100, (Audio.value.volume || 0) + (Number(val) || 0)))
setVolume(next)
}
break
case 'seekDelta':
{
const a = Audio.value.audio
if (a) {
const cur = a.currentTime || 0
const target = Math.max(0, Math.min(a.duration || 0, cur + (Number(val) || 0)))
seekTo(target)
}
}
break
}
}
const initPlayback = async () => {
initPlaylistEventListeners(localUserStore, playSong)
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
if (lastPlayedSong) {
songInfo.value = { ...lastPlayedSong }
mediaSessionController.updateMetadata({
title: lastPlayedSong.name,
artist: lastPlayedSong.singer,
album: lastPlayedSong.albumName || '未知专辑',
artworkUrl: lastPlayedSong.img || defaultCoverImg
})
if (!Audio.value.isPlay) {
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = lastPlayedSong.songmid
if (Audio.value.audio) {
Audio.value.audio.currentTime = userInfo.value.currentTime
}
}
try {
const url = await getSongRealUrl(toRaw(lastPlayedSong))
setUrl(url)
} catch {}
} else {
if (Audio.value.audio) {
mediaSessionController.updatePlaybackState(
Audio.value.audio.paused ? 'paused' : 'playing'
)
}
}
}
}
savePositionInterval = window.setInterval(() => {
if (Audio.value.isPlay) {
userInfo.value.currentTime = Audio.value.currentTime
}
}, 1000)
window.addEventListener('global-music-control', onGlobalCtrl)
controlAudio.subscribe('ended', () => {
window.requestAnimationFrame(() => {
void playNext()
})
})
}
const destroyPlayback = () => {
destroyPlaylistEventListeners()
window.removeEventListener('global-music-control', onGlobalCtrl)
if (savePositionInterval !== null) {
clearInterval(savePositionInterval)
savePositionInterval = null
}
}
export {
songInfo,
playMode,
isLoadingSong,
initPlayback,
destroyPlayback,
playSong,
playNext,
playPrevious,
updatePlayMode,
togglePlayPause,
handlePlay,
handlePause,
setVolume,
seekTo
}

View File

@@ -76,35 +76,27 @@ export function installGlobalMusicControls() {
dispatch('toggle')
} else if (e.code === 'ArrowUp') {
e.preventDefault()
controlAudio.setVolume(controlAudio.Audio.volume + 5)
dispatch('volumeDelta', 5)
} else if (e.code === 'ArrowDown') {
e.preventDefault()
controlAudio.setVolume(controlAudio.Audio.volume - 5)
} else if (
e.code === 'ArrowLeft' &&
controlAudio.Audio.audio &&
controlAudio.Audio.audio.currentTime >= 0
) {
controlAudio.Audio.audio.currentTime -= 5
} else if (
e.code === 'ArrowRight' &&
controlAudio.Audio.audio &&
controlAudio.Audio.audio.currentTime <= controlAudio.Audio.audio.duration
) {
controlAudio.Audio.audio.currentTime += 5
dispatch('volumeDelta', -5)
} else if (e.code === 'ArrowLeft') {
dispatch('seekDelta', -5)
} else if (e.code === 'ArrowRight') {
dispatch('seekDelta', 5)
}
}, 100)
}
document.addEventListener('keydown', onKeyDown)
// 监听音频结束事件,根据播放模式播放下一首
controlAudio.subscribe('ended', () => {
window.requestAnimationFrame(() => {
console.log('播放结束')
dispatch('playNext')
})
})
// // 监听音频结束事件,根据播放模式播放下一首
// controlAudio.subscribe('ended', () => {
// window.requestAnimationFrame(() => {
// console.log('播放结束')
// dispatch('playNext')
// })
// })
// 托盘或系统快捷键回调(若存在)
try {
const removeMusicCtrlListener = (window as any).api?.onMusicCtrl?.(() => {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, toRaw } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import { ChevronRightIcon, RefreshIcon } from 'tdesign-icons-vue-next'
type MusicItem = {
hash?: string
@@ -24,10 +25,17 @@ const scanDirs = ref<string[]>([])
const songs = ref<MusicItem[]>([])
const loading = ref(false)
const matching = ref<Record<string | number, boolean>>({})
const batchState = ref({ total: 0, done: 0, running: false })
const showMatchModal = ref(false)
const matchResults = ref<any[]>([])
const matchTargetSong = ref<MusicItem | null>(null)
const sourcesOrder = ['wy', 'tx', 'kg', 'kw', 'mg']
// 保留占位,后续可扩展更多菜单
// const showMoreDropdown = ref(false)
const playlistOptions = ref<{ label: string; value: string }[]>([])
const selectedPlaylistId = ref<string>('')
const showDirModal = ref(false)
const newDirInput = ref('')
// const newDirInput = ref('')
const hasDirs = computed(() => scanDirs.value.length > 0)
@@ -36,10 +44,12 @@ const selectDirs = async () => {
if (Array.isArray(dirs)) {
scanDirs.value = Array.from(new Set([...(scanDirs.value || []), ...dirs]))
}
showDirModal.value = true
}
const saveDirs = async () => {
await (window as any).api.localMusic.setDirs(toRaw(scanDirs.value))
showDirModal.value = false
MessagePlugin.success('目录已保存')
}
@@ -83,6 +93,136 @@ const ensureDuration = async (song: MusicItem) => {
await p
}
const parseIntervalToSec = (interval?: string) => {
if (!interval) return 0
const mm = parseInt(interval.split(':')[0])
const ss = parseInt(interval.split(':')[1])
return mm * 60 + ss
}
const rankCandidate = (song: MusicItem, it: any) => {
const nameScore = strSim(it.name || '', (song.name || '').trim())
const artistScore = strSim((it.singer || '').trim(), (song.singer || '').trim())
const targetSec = parseIntervalToSec(song.interval)
const its = Number(it.interval || 0)
let durationScore = 0
if (targetSec > 0 && its > 0) {
const diff = Math.abs(its - targetSec)
durationScore = diff <= 2 ? 1 : diff <= 5 ? 0.6 : diff <= 10 ? 0.3 : 0
}
return nameScore * 0.6 + durationScore * 0.3 + artistScore * 0.1
}
const searchAcrossSources = async (keyword: string) => {
const api = (window as any).api
const resAll: any[] = []
for (const src of sourcesOrder) {
try {
const res = await api.music.requestSdk('search', {
source: src,
keyword,
page: 1,
limit: 20
})
if (Array.isArray(res?.list)) {
for (const it of res.list) resAll.push({ ...it, source: src })
}
} catch {}
}
return resAll
}
const pickBestMatch = async (song: MusicItem) => {
await ensureDuration(song)
const keyword =
song.name ||
song.path
?.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, '') ||
''
const all = await searchAcrossSources(keyword)
let best: any = null
let bestScore = 0
for (const it of all) {
const score = rankCandidate(song, it)
if (score > bestScore) {
bestScore = score
best = it
}
if (bestScore >= 0.9) break
}
return { best, bestScore, all }
}
const openAccurateMatch = async (song: MusicItem) => {
matching.value[song.songmid] = true
try {
const { all } = await pickBestMatch(song)
matchResults.value = all
matchTargetSong.value = song
showMatchModal.value = true
} finally {
matching.value[song.songmid] = false
}
}
const applyMatch = async (candidate: any) => {
const song = matchTargetSong.value
if (!song) return
try {
let pic = candidate.img
if (!pic) {
const p = await (window as any).api.music.requestSdk('getPic', {
source: candidate.source,
songInfo: candidate
})
if (typeof p !== 'object') pic = p
}
const writeRes = await (window as any).api.localMusic.writeTags(
song.path,
{
name: candidate.name,
singer: candidate.singer,
albumName: candidate.albumName,
lrc: null,
img: pic,
source: candidate.source
},
{ cover: true, lyrics: false }
)
if (writeRes?.success) {
song.name = candidate.name
song.singer = candidate.singer
song.albumName = candidate.albumName
song.img = pic || song.img
MessagePlugin.success('已应用匹配结果')
showMatchModal.value = false
} else {
MessagePlugin.error(writeRes?.message || '写入失败')
}
} catch (e: any) {
MessagePlugin.error(e?.message || '匹配失败')
}
}
const playAll = () => {
if (songs.value.length === 0) return
if ((window as any).musicEmitter) {
;(window as any).musicEmitter.emit('replacePlaylist', toRaw(songs.value) as any)
}
}
const addAllToPlaylist = () => {
if (songs.value.length === 0) return
if ((window as any).musicEmitter) {
for (const s of songs.value) {
;(window as any).musicEmitter.emit('addToPlaylistEnd', toRaw(s) as any)
}
MessagePlugin.success('已将全部加入播放列表')
}
}
const scanLibrary = async () => {
if (!hasDirs.value) {
MessagePlugin.warning('请先选择扫描目录')
@@ -167,77 +307,12 @@ const matchTags = async (song: MusicItem) => {
if (!song || !song.path) return
matching.value[song.songmid] = true
try {
await ensureDuration(song)
const keyword =
song.name ||
song.path
?.split(/[\\/]/)
.pop()
?.replace(/\.[^.]+$/, '')
const sources = ['wy', 'tx', 'kg']
let best: any = null
let bestScore = 0
for (const src of sources) {
const res = await (window as any).api.music.requestSdk('search', {
source: src,
keyword,
page: 1,
limit: 20
})
if (Array.isArray(res?.list)) {
for (const it of res.list) {
const nameScore = strSim(it.name || '', keyword || '')
let durationScore = 0
if (song.interval) {
const mm = parseInt(song.interval.split(':')[0])
const ss = parseInt(song.interval.split(':')[1])
const sec = mm * 60 + ss
const its = Number(it.interval || 0)
const diff = Math.abs(its - sec)
durationScore = diff <= 2 ? 1 : diff <= 5 ? 0.6 : 0
}
const score = nameScore * 0.7 + durationScore * 0.3
if (score > bestScore) {
bestScore = score
best = { ...it, source: src }
}
}
}
if (bestScore >= 0.85) break
}
const { best } = await pickBestMatch(song)
if (!best) {
MessagePlugin.warning('未匹配到合适标签')
return
}
let pic = best.img
if (!pic) {
const p = await (window as any).api.music.requestSdk('getPic', {
source: best.source,
songInfo: best
})
if (typeof p !== 'object') pic = p
}
const writeRes = await (window as any).api.localMusic.writeTags(
song.path,
{
name: best.name,
singer: best.singer,
albumName: best.albumName,
lrc: null,
img: pic,
source: best.source
},
{ cover: true, lyrics: false }
)
if (writeRes?.success) {
song.name = best.name
song.singer = best.singer
song.albumName = best.albumName
song.img = pic || song.img
MessagePlugin.success('标签已写入')
} else {
MessagePlugin.error(writeRes?.message || '写入失败')
}
await applyMatch(best)
} finally {
matching.value[song.songmid] = false
}
@@ -245,9 +320,16 @@ const matchTags = async (song: MusicItem) => {
const matchBatch = async () => {
const need = songs.value.filter((s) => !s.img || !s.singer || !s.albumName)
if (need.length === 0) {
MessagePlugin.warning('没有需要匹配的歌曲')
return
}
batchState.value = { total: need.length, done: 0, running: true }
for (const it of need) {
await matchTags(it)
batchState.value.done++
}
batchState.value.running = false
MessagePlugin.success('批量匹配完成')
}
@@ -266,118 +348,184 @@ onMounted(async () => {
<template>
<div class="local-container">
<n-card title="本地音乐库" size="medium">
<div class="controls">
<n-button type="primary" size="small" @click="showDirModal = true">目录管理</n-button>
<n-button size="small" :disabled="!hasDirs || loading" @click="scanLibrary"
>重新扫描</n-button
<div class="local-header">
<div class="left-container">
<h2 class="title">
本地音乐库<span style="font-size: 12px; color: #999"> {{ songs.length }} </span>
</h2>
</div>
<div class="right-container">
<t-button shape="round" theme="primary" variant="text" @click="showDirModal = true">
<span style="display: flex; align-items: center"
><span style="font-weight: bold">选择目录</span>
<chevron-right-icon
:fill-color="'transparent'"
:stroke-color="'currentColor'"
:stroke-width="2.5"
/></span>
</t-button>
</div>
</div>
<div class="controls">
<t-button
theme="primary"
style="padding: 6px 9px; border-radius: 8px; height: 36px"
@click="playAll"
>
<span style="margin-left: 3px">播放全部</span>
<template #icon>
<span class="iconfont icon-bofang"></span>
</template>
</t-button>
<t-button
theme="default"
style="padding: 6px 9px; border-radius: 8px; height: 36px; width: 36px"
@click="scanLibrary"
>
<template #icon
><refresh-icon
:fill-color="'transparent'"
:stroke-color="'currentColor'"
:stroke-width="1.5"
/></template>
</t-button>
<t-button size="small" @click="addAllToPlaylist">添加全部到播放列表</t-button>
<n-button size="small" @click="clearScan">清空所有</n-button>
<n-button size="small" :disabled="songs.length === 0" @click="matchBatch">批量匹配</n-button>
<!-- <n-select
v-model:value="selectedPlaylistId"
:options="playlistOptions"
size="small"
placeholder="选择本地歌单"
/> -->
<div v-if="batchState.running" style="margin-left: 8px; font-size: 12px; color: #999">
{{ batchState.done }}/{{ batchState.total }}
</div>
</div>
<n-modal v-model:show="showDirModal" preset="dialog" title="选择本地文件夹">
<div>
<div style="margin-bottom: 10px; color: #666; font-size: 12px">
你可以添加常用目录文件将即时索引
</div>
<div
v-for="d in scanDirs"
:key="d"
style="display: flex; justify-content: space-between; align-items: center; margin: 10px 0"
>
<n-tag v-for="d in scanDirs" :key="d" type="default" class="dir-tag">{{ d }}</n-tag>
<n-input
v-model:value="newDirInput"
size="small"
placeholder="输入路径或点击选择..."
style="max-width: 280px"
/>
<n-button size="small" @click="selectDirs">选择</n-button>
<n-button
size="small"
@click="
() => {
if (newDirInput) {
scanDirs.value = Array.from(new Set([...(scanDirs.value || []), newDirInput]))
newDirInput = ''
}
}
<span>{{ d }}</span>
<n-button size="tiny" @click="removeDir(d)">删除</n-button>
</div>
<div
style="
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
gap: 8px;
"
>添加</n-button
>
<n-button size="small" @click="saveDirs">保存目录</n-button>
<n-button size="small" :disabled="songs.length === 0" @click="matchBatch"
>批量匹配缺失标签</n-button
>
<n-button size="small" @click="clearScan">清空扫描</n-button>
<n-select
v-model:value="selectedPlaylistId"
:options="playlistOptions"
size="small"
placeholder="选择本地歌单"
/>
<n-button class="flex" style="width: 46%" round @click="selectDirs">添加文件夹</n-button>
<div class="flex" style="width: 8%"></div>
<n-button class="flex" style="width: 46%" round type="primary" @click="saveDirs"
>确认</n-button
>
</div>
</div>
</n-modal>
<n-modal v-model:show="showDirModal" preset="dialog" title="音乐目录管理">
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 10px">
<n-input v-model:value="newDirInput" placeholder="输入路径或点击选择..." />
<n-button @click="selectDirs">选择</n-button>
<n-button
@click="
() => {
if (newDirInput) {
scanDirs.value = Array.from(new Set([...(scanDirs.value || []), newDirInput]))
newDirInput = ''
}
}
"
>添加</n-button
>
<div v-if="songs.length > 0" class="list">
<div class="row header">
<div class="col cover">封面</div>
<div class="col title">标题</div>
<div class="col artist">歌手</div>
<div class="col album">专辑</div>
<div class="col dura">时长</div>
<div class="col ops">操作</div>
</div>
<div v-for="s in songs" :key="s.songmid" class="row">
<div class="col cover">
<img :src="s.img || '/default-cover.png'" alt="cover" />
</div>
<div>
<div
v-for="d in scanDirs"
:key="d"
style="
display: flex;
justify-content: space-between;
align-items: center;
margin: 6px 0;
"
>
<span>{{ d }}</span>
<n-button size="tiny" @click="removeDir(d)">删除</n-button>
</div>
<div style="margin-top: 10px; display: flex; gap: 8px">
<n-button type="primary" @click="saveDirs">保存</n-button>
<n-button @click="scanLibrary">重新扫描</n-button>
<n-button @click="clearScan">清空扫描</n-button>
</div>
</div>
</n-modal>
<div class="list" v-if="songs.length > 0">
<div class="row header">
<div class="col cover">封面</div>
<div class="col title">标题</div>
<div class="col artist">歌手</div>
<div class="col album">专辑</div>
<div class="col dura">时长</div>
<div class="col ops">操作</div>
</div>
<div class="row" v-for="s in songs" :key="s.songmid">
<div class="col cover">
<img :src="s.img || '/default-cover.png'" alt="cover" />
</div>
<div class="col title">{{ s.name }}</div>
<div class="col artist">{{ s.singer }}</div>
<div class="col album">{{ s.albumName }}</div>
<div class="col dura">{{ s.interval || '' }}</div>
<div class="col ops">
<n-button size="tiny" @click="addToPlaylistAndPlay(s)">播放</n-button>
<n-button size="tiny" @click="addToPlaylistEnd(s)">加入播放列表</n-button>
<n-button size="tiny" :loading="matching[s.songmid]" @click="matchTags(s)"
>匹配标签</n-button
<div class="col title">{{ s.name }}</div>
<div class="col artist">{{ s.singer }}</div>
<div class="col album">{{ s.albumName }}</div>
<div class="col dura">{{ s.interval || '' }}</div>
<div class="col ops">
<n-button-group ghost>
<n-button size="tiny" round @click="addToPlaylistAndPlay(s)">播放</n-button>
<n-button size="tiny" round @click="addToPlaylistEnd(s)">加入播放列表</n-button>
<n-button size="tiny" :loading="matching[s.songmid]" round @click="openAccurateMatch(s)"
>精准匹配</n-button
>
<n-button size="tiny" @click="addToLocalPlaylist(s)">添加到本地歌单</n-button>
</div>
<n-button size="tiny" round @click="addToLocalPlaylist(s)">添加到本地歌单</n-button>
</n-button-group>
</div>
</div>
<div v-else class="empty">暂无数据点击选择目录后扫描</div>
</n-card>
</div>
<div v-else class="empty">暂无数据点击选择目录后扫描</div>
<n-modal v-model:show="showMatchModal" preset="dialog" title="选择匹配结果">
<div style="max-height: 60vh; overflow: auto">
<div
v-for="it in matchResults"
:key="(it.id || it.songId) + '_' + it.source"
style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid #eee;
border-radius: 6px;
margin: 6px 0;
"
>
<div style="display: flex; align-items: center; gap: 10px">
<img
:src="it.img || '/default-cover.png'"
style="width: 40px; height: 40px; border-radius: 4px"
/>
<div>
<div style="font-weight: 500">
{{ it.name
}}<span style="margin-left: 8px; color: #999; font-size: 12px">{{
it.source.toUpperCase()
}}</span>
</div>
<div style="font-size: 12px; color: #666">{{ it.singer }} · {{ it.albumName }}</div>
</div>
</div>
<n-button size="tiny" type="primary" @click="applyMatch(it)">使用该结果</n-button>
</div>
</div>
</n-modal>
</div>
</template>
<style scoped>
<style lang="scss" scoped>
.local-container {
padding: 16px;
padding: 0 32px;
height: 100%;
display: flex;
flex-direction: column;
.local-header {
height: 70px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
.left-container {
gap: 8px;
.title {
font-size: 28px;
font-weight: 900;
span {
padding-left: 8px;
font-size: 18px;
}
}
}
}
}
.controls {
display: flex;
@@ -389,9 +537,11 @@ onMounted(async () => {
max-width: 360px;
}
.list {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.row {
display: grid;