mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 03:15:07 +08:00
Compare commits
2 Commits
a277cb7181
...
6ce05d286f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce05d286f | ||
|
|
8209d021de |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
|
||||
4
src/renderer/components.d.ts
vendored
4
src/renderer/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 80,
|
||||
url: ''
|
||||
url: '',
|
||||
eventInit: false
|
||||
})
|
||||
|
||||
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ControlAudioState = {
|
||||
duration: number
|
||||
volume: number
|
||||
url: string
|
||||
eventInit: boolean
|
||||
}
|
||||
|
||||
export type SongList = playList
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 全局音频管理器,用于管理音频源和分析器
|
||||
|
||||
class AudioManager {
|
||||
private static instance: AudioManager
|
||||
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
|
||||
|
||||
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal file
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal 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
|
||||
}
|
||||
@@ -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?.(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user