fix: 酷狗歌单无法播放,软件无法退出,全局播放进入设置无法下一曲 feat:本地播放

This commit is contained in:
sqj
2025-11-15 13:26:42 +08:00
parent a9ad32e8ea
commit a277cb7181
31 changed files with 8730 additions and 2318 deletions

View File

@@ -49,7 +49,6 @@
"@pixi/sprite": "^7.4.3",
"@types/howler": "^2.2.12",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
"axios": "^1.11.0",
"color-extraction": "^1.0.8",

692
playlist-converter.html Normal file
View File

@@ -0,0 +1,692 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>歌单格式转换器</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
font-weight: 300;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
}
.content {
padding: 40px;
}
.upload-section {
background: #f8f9ff;
border: 3px dashed #4facfe;
border-radius: 15px;
padding: 40px;
text-align: center;
margin-bottom: 30px;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-section:hover {
border-color: #00f2fe;
background: #f0f8ff;
transform: translateY(-2px);
}
.upload-section.dragover {
border-color: #00f2fe;
background: #e8f4ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 4em;
color: #4facfe;
margin-bottom: 20px;
}
.upload-text {
font-size: 1.3em;
color: #333;
margin-bottom: 10px;
}
.upload-subtext {
color: #666;
font-size: 0.9em;
}
.file-input {
display: none;
}
.preview-section {
display: none;
margin-bottom: 30px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.preview-title {
font-size: 1.5em;
color: #333;
font-weight: 500;
}
.song-count {
background: #4facfe;
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
}
.preview-content {
background: #f8f9ff;
border-radius: 15px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.song-item {
background: white;
border-radius: 10px;
padding: 15px;
margin-bottom: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.song-item:hover {
transform: translateX(5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.song-number {
background: #4facfe;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 15px;
font-size: 0.9em;
}
.song-info {
flex: 1;
}
.song-name {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.song-artist {
color: #666;
font-size: 0.9em;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 25px;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.btn-secondary {
background: #f1f3f4;
color: #333;
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.result-section {
display: none;
background: #f8f9ff;
border-radius: 15px;
padding: 30px;
text-align: center;
}
.result-icon {
font-size: 4em;
margin-bottom: 20px;
}
.result-success {
color: #28a745;
}
.result-error {
color: #dc3545;
}
.result-message {
font-size: 1.2em;
margin-bottom: 20px;
color: #333;
}
.download-link {
display: inline-block;
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
padding: 12px 30px;
border-radius: 25px;
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
}
.download-link:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.loading {
display: none;
text-align: center;
padding: 40px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #4facfe;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-details {
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 10px;
padding: 15px;
margin-top: 15px;
text-align: left;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #c53030;
max-height: 200px;
overflow-y: auto;
}
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.controls {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎵 澜音歌单格式转换器</h1>
<p>将洛雪音乐平台的歌单转换为澜音音乐的加密格式</p>
</div>
<div class="content">
<div class="upload-section" id="uploadSection">
<div class="upload-icon">📁</div>
<div class="upload-text">点击或拖拽洛雪歌单文件到此处</div>
<div class="upload-subtext">支持 *.json 和 *.lxmc 格式的歌单文件</div>
<input type="file" id="fileInput" class="file-input" accept=".json,.lxmc" />
</div>
<div class="preview-section" id="previewSection">
<div class="preview-header">
<div class="preview-title">歌单预览</div>
<div class="song-count" id="songCount">0 首歌曲</div>
</div>
<div class="preview-content" id="previewContent">
<!-- 歌曲列表将在这里显示 -->
</div>
</div>
<div class="controls" id="controls" style="display: none">
<button class="btn btn-primary" id="convertBtn">
<span>🔐</span>
转换并加密
</button>
<button class="btn btn-secondary" id="resetBtn">
<span>🔄</span>
重新选择
</button>
</div>
<div class="loading" id="loadingSection">
<div class="spinner"></div>
<div>正在转换中...</div>
</div>
<div class="result-section" id="resultSection">
<div class="result-icon" id="resultIcon"></div>
<div class="result-message" id="resultMessage"></div>
<a href="#" class="download-link" id="downloadLink" style="display: none">
<span>📥</span>
下载澜音歌单
</a>
<div class="error-details" id="errorDetails" style="display: none"></div>
</div>
</div>
</div>
<script>
// 全局变量
let currentPlaylist = null
let convertedPlaylist = null
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
// Gzip解压函数
async function decompressGzip(arrayBuffer) {
try {
// 使用浏览器的 DecompressionStream API
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'))
const decompressed = await new Response(stream).arrayBuffer()
return new TextDecoder().decode(decompressed)
} catch (error) {
throw new Error('Gzip解压失败: ' + error.message)
}
}
// DOM元素
const uploadSection = document.getElementById('uploadSection')
const fileInput = document.getElementById('fileInput')
const previewSection = document.getElementById('previewSection')
const previewContent = document.getElementById('previewContent')
const songCount = document.getElementById('songCount')
const controls = document.getElementById('controls')
const convertBtn = document.getElementById('convertBtn')
const resetBtn = document.getElementById('resetBtn')
const loadingSection = document.getElementById('loadingSection')
const resultSection = document.getElementById('resultSection')
const resultIcon = document.getElementById('resultIcon')
const resultMessage = document.getElementById('resultMessage')
const downloadLink = document.getElementById('downloadLink')
const errorDetails = document.getElementById('errorDetails')
// 文件上传处理
uploadSection.addEventListener('click', () => fileInput.click())
uploadSection.addEventListener('dragover', (e) => {
e.preventDefault()
uploadSection.classList.add('dragover')
})
uploadSection.addEventListener('dragleave', () => {
uploadSection.classList.remove('dragover')
})
uploadSection.addEventListener('drop', (e) => {
e.preventDefault()
uploadSection.classList.remove('dragover')
const files = e.dataTransfer.files
if (files.length > 0) {
handleFile(files[0])
}
})
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0]
if (file) {
handleFile(file)
}
})
// 按钮事件
convertBtn.addEventListener('click', convertPlaylist)
resetBtn.addEventListener('click', resetConverter)
// 处理文件
function handleFile(file) {
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.json')) {
// 处理JSON文件
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = JSON.parse(e.target.result)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('JSON文件解析失败: ' + error.message)
}
}
reader.readAsText(file)
} else if (fileName.endsWith('.lxmc')) {
// 处理LXMC文件gzip压缩
const reader = new FileReader()
reader.onload = async (e) => {
try {
const arrayBuffer = e.target.result
const decompressed = await decompressGzip(arrayBuffer)
const content = JSON.parse(decompressed)
currentPlaylist = content
displayPreview(content)
} catch (error) {
showError('LXMC文件处理失败: ' + error.message)
}
}
reader.readAsArrayBuffer(file)
} else {
showError('请选择JSON或LXMC格式的文件')
return
}
}
// 显示预览
function displayPreview(playlist) {
previewContent.innerHTML = ''
const playlistData =
playlist.type === 'playListPart_v2' && playlist.data ? playlist.data : playlist
const songList = playlistData.list
if (!songList || !Array.isArray(songList)) {
showError('无效的歌单格式:缺少歌曲列表')
return
}
// 显示歌曲列表
songList.forEach((song, index) => {
const songItem = document.createElement('div')
songItem.className = 'song-item'
songItem.innerHTML = `
<div class="song-number">${index + 1}</div>
<div class="song-info">
<div class="song-name">${song.name || '未知歌曲'}</div>
<div class="song-artist">${song.singer || '未知艺术家'}${song.interval || '0:00'}</div>
</div>
`
previewContent.appendChild(songItem)
})
songCount.textContent = `${songList.length} 首歌曲`
previewSection.style.display = 'block'
controls.style.display = 'flex'
uploadSection.style.display = 'none'
}
// 转换歌单
function convertPlaylist() {
if (!currentPlaylist) return
showLoading()
try {
// 转换格式
const convertedSongs = convertToTargetFormat(currentPlaylist)
// 加密
const encryptedData = encryptPlaylist(convertedSongs)
convertedPlaylist = {
original: currentPlaylist,
converted: convertedSongs,
encrypted: encryptedData
}
showSuccess(convertedPlaylist)
} catch (error) {
showError('转换失败: ' + error.message, error.stack)
}
}
// 转换为目标格式
function convertToTargetFormat(originalPlaylist) {
if (originalPlaylist.type === 'playListPart_v2' && originalPlaylist.data) {
originalPlaylist = originalPlaylist.data
}
if (!originalPlaylist.list || !Array.isArray(originalPlaylist.list)) {
throw new Error('原始歌单格式无效')
}
return originalPlaylist.list.map((song) => {
// 从meta中提取额外信息
const meta = song.meta || {}
return {
songmid: meta.hash || meta.songId,
singer: song.singer || '未知艺术家',
name: song.name || '未知歌曲',
albumName: meta.albumName || '未知专辑',
albumId: meta.albumId,
source: song.source || 'unknown',
interval: song.interval || '0:00',
img: meta.picUrl || '',
lrc: null,
types: meta.qualitys,
_types: meta._qualitys,
typeUrl: {},
url: ''
}
})
}
// 转换音质类型
function convertQualityTypes(qualities) {
const types = {}
if (!qualities || !Array.isArray(qualities)) {
types['128k'] = { size: '未知' }
return types
}
qualities.forEach((quality) => {
if (typeof quality.type !== 'string') return
const q = quality.type.toLowerCase()
if (q.includes('flac')) {
if (q.includes('24')) {
types['flac24bit'] = { size: '未知' }
} else {
types['flac'] = { size: '未知' }
}
} else if (q.includes('320')) {
types['320k'] = { size: '未知' }
} else if (q.includes('128')) {
types['128k'] = { size: '未知' }
} else {
types[q] = { size: '未知' }
}
})
return Object.keys(types).length > 0 ? types : { '128k': { size: '未知' } }
}
// 生成随机ID
function generateRandomId() {
return Math.random().toString(36).substr(2, 9)
}
// 加密歌单
function encryptPlaylist(songs) {
const dataToEncrypt = JSON.stringify(songs)
return CryptoJS.AES.encrypt(dataToEncrypt, SECRET_KEY).toString()
}
// 显示加载状态
function showLoading() {
loadingSection.style.display = 'block'
controls.style.display = 'none'
resultSection.style.display = 'none'
}
// 显示成功结果
function showSuccess(result) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-success'
resultIcon.textContent = '✅'
resultMessage.textContent = '歌单转换成功!'
// 创建下载链接
const encryptedData = result.encrypted
const blob = new Blob([encryptedData], {
type: 'application/octet-stream'
})
const url = URL.createObjectURL(blob)
downloadLink.href = url
downloadLink.download = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
downloadLink.style.display = 'inline-block'
// 清理之前的URL
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
downloadLink.dataset.url = url
}
// 显示错误信息
function showError(message, details = null) {
loadingSection.style.display = 'none'
resultSection.style.display = 'block'
resultIcon.className = 'result-icon result-error'
resultIcon.textContent = '❌'
resultMessage.textContent = message
if (details) {
errorDetails.textContent = details
errorDetails.style.display = 'block'
}
downloadLink.style.display = 'none'
}
// 重置转换器
function resetConverter() {
currentPlaylist = null
convertedPlaylist = null
uploadSection.style.display = 'block'
previewSection.style.display = 'none'
controls.style.display = 'none'
loadingSection.style.display = 'none'
resultSection.style.display = 'none'
fileInput.value = ''
previewContent.innerHTML = ''
errorDetails.style.display = 'none'
// 清理下载链接
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
delete downloadLink.dataset.url
}
}
// 页面卸载时清理资源
window.addEventListener('beforeunload', () => {
if (downloadLink.dataset.url) {
URL.revokeObjectURL(downloadLink.dataset.url)
}
})
</script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
export default interface localList {
singer: string
name: string
albumName: string
interval: string
duration: string
img: string
lrc: null | string
}

View File

@@ -11,5 +11,6 @@ export default interface PlayList {
lrc: null | string
types: string[]
_types: Record<string, any>
typeUrl: Record<string, any>
typeUrl?: Record<string, any>
url?: string
}

View File

@@ -1,11 +1,13 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
// import initLocalMusicEvents from './localMusic'
import { app, powerSaveBlocker } from 'electron'
import { type BrowserWindow, ipcMain } from 'electron'
export default function InitEventServices(mainWindow: BrowserWindow) {
InitPluginService()
aiEvents(mainWindow)
// initLocalMusicEvents()
basisEvent(mainWindow)
}

View File

@@ -0,0 +1,334 @@
import { ipcMain, dialog, app } from 'electron'
import fs from 'fs'
import fsp from 'fs/promises'
import path from 'node:path'
import crypto from 'crypto'
import { localMusicIndexService } from '../services/LocalMusicIndex'
// remove static import to avoid runtime failures if native module is missing
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.aac', '.m4a', '.ogg', '.wma'])
function genId(input: string) {
return crypto.createHash('md5').update(input).digest('hex')
}
async function walkDir(dir: string, results: string[]) {
try {
const items = await fsp.readdir(dir, { withFileTypes: true })
for (const item of items) {
const full = path.join(dir, item.name)
if (item.isDirectory()) {
await walkDir(full, results)
} else {
const ext = path.extname(item.name).toLowerCase()
if (AUDIO_EXTS.has(ext)) results.push(full)
}
}
} catch { }
}
function readTags(filePath: string) {
try {
const taglib = require('node-taglib-sharp')
const f = taglib.File.createFromPath(filePath)
const tag = f.tag
const title = tag.title || ''
const album = tag.album || ''
const performers = Array.isArray(tag.performers) ? tag.performers : []
let img = ''
if (Array.isArray(tag.pictures) && tag.pictures.length > 0) {
try {
const buf = tag.pictures[0].data
const mime = tag.pictures[0].mimeType || 'image/jpeg'
img = `data:${mime};base64,${Buffer.from(buf).toString('base64')}`
} catch { }
}
let lrc: string | null = null
try {
const raw = tag.lyrics || ''
if (raw && typeof raw === 'string') {
lrc = normalizeLyricsToLrc(raw)
}
} catch { }
f.dispose()
return { title, album, performers, img, lrc }
} catch {
return { title: '', album: '', performers: [], img: '', lrc: null }
}
}
// 将两种逐字/行内时间歌词统一转换为标准LRC仅保留行时间标签
function normalizeLyricsToLrc(input: string): string {
const lines = String(input).replace(/\r/g, '').split('\n')
const msFormat = (timeMs: number) => {
if (!Number.isFinite(timeMs)) return ''
const m = Math.floor(timeMs / 60000)
const s = Math.floor((timeMs % 60000) / 1000)
const ms = Math.floor(timeMs % 1000)
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 }
const off = /^\[offset:[+-]?\d+\]$/i.exec(line.trim())
if (off) { out.push(line.trim()); continue }
let mNew = /^\[(\d+),(\d+)\](.*)$/.exec(line)
if (mNew) {
const startMs = parseInt(mNew[1])
let text = mNew[3] || ''
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mNew[3] || '')) {
text = text.replace(/[()]/g, '')
}
text = text.replace(/\s+/g, ' ').trim()
const tag = msFormat(startMs)
out.push(`${tag}${text}`)
continue
}
let mOld = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
if (mOld) {
let text = mOld[2] || ''
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mOld[2] || '')) {
text = text.replace(/[()]/g, '')
}
text = text.replace(/\s+/g, ' ').trim()
const tag = `[${mOld[1]}]`
out.push(`${tag}${text}`)
continue
}
out.push(line)
}
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 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'] })
if (res.canceled) return []
return res.filePaths
})
ipcMain.handle('local-music:scan', async (_e, dirs: string[]) => {
if (!Array.isArray(dirs) || dirs.length === 0) {
return []
}
const existsDirs = dirs.filter((d) => {
try {
return fs.existsSync(d)
} catch {
return false
}
})
const files: 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 }
try {
tags = readTags(p)
} 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)
if (segs.length >= 2) {
singer = segs[0]
name = segs.slice(1).join(' ')
} else {
name = noExt
}
} else {
singer = Array.isArray(tags.performers) && tags.performers.length > 0 ? tags.performers[0] : ''
}
const songmid = genId(p)
const item = {
songmid,
singer: singer || '未知艺术家',
name: name || '未知曲目',
albumName: tags.album || '未知专辑',
albumId: 0,
source: 'local',
interval: '',
img: tags.img || '',
lrc: tags.lrc || null,
types: [],
_types: {},
typeUrl: {},
url: 'file://' + p,
path: p
}
return item
})
await localMusicIndexService.setDirs(existsDirs)
await localMusicIndexService.upsertSongs(list)
try {
return JSON.stringify(list)
} catch {
return '[]'
}
} catch (e) {
return '[]'
}
})
ipcMain.handle('local-music:write-tags', async (_e, payload: any) => {
const { filePath, songInfo, tagWriteOptions } = payload || {}
if (!filePath || !fs.existsSync(filePath)) return { success: false, message: '文件不存在' }
try {
const taglib = require('node-taglib-sharp')
const songFile = taglib.File.createFromPath(filePath)
taglib.Id3v2Settings.forceDefaultVersion = true
taglib.Id3v2Settings.defaultVersion = 3
songFile.tag.title = songInfo?.name || '未知曲目'
songFile.tag.album = songInfo?.albumName || '未知专辑'
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
songFile.tag.performers = artists
songFile.tag.albumArtists = artists
if (tagWriteOptions?.lyrics && songInfo?.lrc) {
const normalized = normalizeLyricsToLrc(songInfo.lrc)
songFile.tag.lyrics = normalized
}
if (tagWriteOptions?.cover && songInfo?.img) {
try {
if (songInfo.img.startsWith('data:')) {
const m = songInfo.img.match(/^data:(.*?);base64,(.*)$/)
if (m) {
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 { }
}
}
} catch { }
}
songFile.save()
songFile.dispose()
const songmid = genId(filePath)
await localMusicIndexService.upsertSong({
songmid,
singer: songInfo?.singer || '未知艺术家',
name: songInfo?.name || '未知曲目',
albumName: songInfo?.albumName || '未知专辑',
albumId: 0,
source: 'local',
interval: '',
img: songInfo?.img || '',
lrc: songInfo?.lrc || null,
types: [],
_types: {},
typeUrl: {},
url: 'file://' + filePath,
path: filePath
})
return { success: true }
} catch (e: any) {
return { success: false, message: e?.message || '写入失败' }
}
})
ipcMain.handle('local-music:get-dirs', async () => {
return localMusicIndexService.getDirs()
})
ipcMain.handle('local-music:set-dirs', async (_e, dirs: string[]) => {
await localMusicIndexService.setDirs(Array.isArray(dirs) ? dirs : [])
return { success: true }
})
ipcMain.handle('local-music:get-list', async () => {
return localMusicIndexService.getAllSongs()
})
ipcMain.handle('local-music:get-url', async (_e, id: string | number) => {
const u = localMusicIndexService.getUrlById(id)
if (!u) return { error: '未找到本地文件' }
return u
})
ipcMain.handle('local-music:clear-index', async () => {
try {
const fn = (localMusicIndexService as any).clearSongs
if (typeof fn === 'function') {
await fn.call(localMusicIndexService)
return { success: true }
}
const dirs = localMusicIndexService.getDirs()
const file = require('node:path').join(app.getPath('userData'), 'local-music-index.json')
const data = { songs: {}, dirs: Array.isArray(dirs) ? dirs : [], updatedAt: Date.now() }
await require('fs/promises').writeFile(file, JSON.stringify(data, null, 2))
return { success: true }
} catch (e: any) {
return { success: false, message: e?.message || '清空失败' }
}
})

View File

@@ -28,6 +28,8 @@ const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
// 切换桌面歌词
ipcMain.on('change-desktop-lyric', (_event, val: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
if (val) {
lyricWin?.show()
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
@@ -39,17 +41,37 @@ const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
// 音乐名称更改
ipcMain.on('play-song-change', (_, title) => {
if (!title) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-song-change', title)
})
// 音乐歌词更改
ipcMain.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-change', lyricData)
})
// 当前行索引变化(用于立即高亮切换)
ipcMain.on('play-lyric-index', (_, index: number) => {
if (index === undefined || index === null) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-index', index)
})
// 当前行进度(用于控制 30% 时机的延迟替换)
ipcMain.on('play-lyric-progress', (_, payload: { index: number; progress: number }) => {
if (!payload || !lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed())
return
lyricWin?.webContents.send('play-lyric-progress', payload)
})
// 播放状态更改(播放/暂停)
ipcMain.on('play-status-change', (_, status: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-status-change', status)
})

View File

@@ -11,6 +11,12 @@
import { BrowserWindow } from 'electron'
let mainWindow: BrowserWindow | null = null
export function initPluginNotice(mainWindowInstance: BrowserWindow): void {
mainWindow = mainWindowInstance
}
export interface PluginNoticeData {
type: 'error' | 'info' | 'success' | 'warn' | 'update'
data: {
@@ -100,7 +106,6 @@ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: stri
// console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
// 获取主窗口实例
const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed())
if (!mainWindow) {
console.warn('[CeruMusic] 未找到主窗口,无法发送通知')
return
@@ -152,7 +157,6 @@ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: stri
getDefaultMessage(noticeData.type, noticeData.data, baseNoticeData.pluginName),
actions: [{ text: '我知道了', type: 'confirm', primary: true }]
}
mainWindow.webContents.send('plugin-notice', infoNotice)
}
} catch (error: any) {

View File

@@ -23,6 +23,8 @@ import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import initLyricIpc from './events/lyric'
import { initPluginNotice } from './events/pluginNotice'
import './events/localMusic'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -262,7 +264,7 @@ function createWindow(): void {
return { action: 'deny' }
})
InitEventServices(mainWindow)
initPluginNotice(mainWindow)
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {

View File

@@ -0,0 +1,113 @@
import path from 'node:path'
import fs from 'fs'
import fsp from 'fs/promises'
import crypto from 'crypto'
import { configManager } from './ConfigManager'
export interface MusicItem {
hash?: string
singer: string
name: string
albumName: string
albumId: number
source: string
interval: string
songmid: number | string
img: string
lrc: null | string
types: string[]
_types: Record<string, any>
typeUrl: Record<string, any>
url?: string
path?: string
}
type IndexSchema = {
songs: Record<string, MusicItem>
dirs: string[]
updatedAt: number
}
function md5(input: string) {
return crypto.createHash('md5').update(input).digest('hex')
}
export class LocalMusicIndexService {
private indexFile: string
private data: IndexSchema = { songs: {}, dirs: [], updatedAt: Date.now() }
constructor() {
const userData = require('electron').app.getPath('userData')
this.indexFile = path.join(userData, 'local-music-index.json')
this.load()
}
private load() {
try {
if (fs.existsSync(this.indexFile)) {
const raw = fs.readFileSync(this.indexFile, 'utf-8')
const obj = JSON.parse(raw)
if (obj && typeof obj === 'object') this.data = obj as IndexSchema
}
} catch {
this.data = { songs: {}, dirs: [], updatedAt: Date.now() }
}
}
private async save() {
try {
const dir = path.dirname(this.indexFile)
await fsp.mkdir(dir, { recursive: true })
await fsp.writeFile(this.indexFile, JSON.stringify(this.data, null, 2))
} catch {}
}
getDirs() {
return [...(this.data.dirs || [])]
}
async setDirs(dirs: string[]) {
this.data.dirs = Array.from(new Set(dirs.filter(Boolean)))
this.data.updatedAt = Date.now()
await this.save()
}
getAllSongs(): MusicItem[] {
return Object.values(this.data.songs)
}
getSongById(id: string | number): MusicItem | null {
const key = String(id)
return this.data.songs[key] || null
}
getUrlById(id: string | number): string | null {
const s = this.getSongById(id)
if (!s) return null
if (s.url && typeof s.url === 'string') return s.url
if (s.path && typeof s.path === 'string') return 'file://' + s.path
return null
}
async upsertSong(item: MusicItem) {
const key = String(item.songmid ?? md5(String(item.path || '')))
item.songmid = key
item.hash = md5(`${item.name}-${item.singer}-${item.source}`)
this.data.songs[key] = item
this.data.updatedAt = Date.now()
await this.save()
}
async upsertSongs(items: MusicItem[]) {
for (const it of items) {
const key = String(it.songmid ?? md5(String(it.path || '')))
it.songmid = key
it.hash = md5(`${it.name}-${it.singer}-${it.source}`)
this.data.songs[key] = it
}
this.data.updatedAt = Date.now()
await this.save()
}
}
export const localMusicIndexService = new LocalMusicIndexService()

View File

@@ -175,7 +175,6 @@ export default class PlayListSongs {
if (!Array.isArray(songs) || songs.length === 0) {
return
}
// 验证和过滤有效歌曲
const validSongs = songs.filter(PlayListUtils.isValidSong)
if (validSongs.length === 0) {

View File

@@ -32,7 +32,7 @@ class LyricWindow {
const bounds = this.win?.getBounds()
if (bounds) {
const { width, height } = bounds
console.log('歌词窗口缩放:', width, height);
console.log('歌词窗口缩放:', width, height)
lyricStore.set({
...lyricStore.get(),
@@ -41,6 +41,9 @@ class LyricWindow {
})
}
})
this.win?.on('closed', () => {
this.win = null
})
}
/**
* 创建主窗口

View File

@@ -126,6 +126,16 @@ interface CustomAPI {
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
localMusic: {
selectDirs: () => Promise<string[]>
scan: (dirs: string[]) => Promise<any[]>
writeTags: (
filePath: string,
songInfo: any,
tagWriteOptions: any
) => Promise<{ success: boolean; message?: string }>
}
}
declare global {

View File

@@ -190,6 +190,31 @@ const api = {
ipcRenderer.invoke('directory-settings:get-directory-size', dirPath)
},
// 本地音乐管理
localMusic: {
selectDirs: () => ipcRenderer.invoke('local-music:select-dirs'),
scan: async (dirs: string[]) => {
const res = await ipcRenderer.invoke('local-music:scan', dirs)
if (typeof res === 'string') {
try {
return JSON.parse(res)
} catch {
return []
}
}
return Array.isArray(res) ? res : []
},
writeTags: (filePath: string, songInfo: any, tagWriteOptions: any) =>
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)
,
clearIndex: () => ipcRenderer.invoke('local-music:clear-index')
},
// 插件通知相关
pluginNotice: {
onPluginNotice(callback: (data: string) => any) {

View File

@@ -20,9 +20,14 @@ declare module 'vue' {
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
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

@@ -31,6 +31,14 @@ onMounted(() => {
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 全局键盘/托盘播放控制安装(解耦出组件)
import('@renderer/utils/audio/globalControls')
.then((m) => m.installGlobalMusicControls())
.catch(() => {})
import('@renderer/utils/lyrics/desktopLyricBridge')
.then((m) => m.installDesktopLyricBridge())
.catch(() => {})
// 全局监听来自主进程的播放控制事件,确保路由切换也可响应
const forward = (name: string, val?: any) => {
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))

View File

@@ -30,7 +30,7 @@ interface Props {
show?: boolean
coverImage?: string
songId?: string | null
songInfo: SongList | { songmid: number | null | string }
songInfo: SongList | { songmid: number | null | string; lrc: string | null }
mainColor: string
}
@@ -101,7 +101,6 @@ watch(
active = false
abort.abort()
})
// 工具函数:清洗响应式对象,避免序列化问题
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
@@ -173,35 +172,100 @@ watch(
let parsedLyrics: LyricLine[] = []
if (source === 'wy' || source === 'tx') {
// 网易云 / QQ 音乐:优先尝试 TTML
// 网易云 / QQ 音乐:优先尝试 TTML,同时准备备用方案
// 1. 立即启动 SDK (回退) 请求,但不 await
// 将其 Promise 存储在 sdkPromise 变量中
const sdkPromise = (async () => {
try {
const lyricData = await window.api.music.requestSdk('getLyric', {
source,
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
// 注意:这里的 abort.signal 是用于 TTML 的
// 如果 requestSdk 也支持 signal你可以考虑也传入
})
console.log('sdkPromise', lyricData)
// 依赖外部的 active 检查
if (!active) return null
let lyrics: null | LyricLine[] = null
if (lyricData?.crlyric) {
lyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
lyrics = parseLrc(lyricData.lyric)
}
lyrics = mergeTranslation(lyrics as any, lyricData?.tlyric)
// 如果 SDK 也拿不到歌词,返回 null
if (!lyrics || lyrics.length === 0) {
return null
}
return lyrics
} catch (err: any) {
// 如果 SDK 请求失败,抛出错误
// 这样当 TTML 也失败时,可以捕获到这个 SDK 错误
throw new Error(`SDK request failed: ${err.message}`)
}
})()
// 2. 尝试 TTML (主要) 请求
try {
const res = await (
await fetch(
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`,
{
signal: abort.signal
})
signal: abort.signal // TTML 请求使用 abort signal
}
)
).text()
if (!active) return
if (!res || res.length < 100) throw new Error('ttml 无歌词')
parsedLyrics = parseTTML(res).lines
} catch {
// 回退到统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source,
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
})
if (!active) return
if (lyricData?.crlyric) {
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
if (!res || res.length < 100) {
throw new Error('ttml 无歌词') // 抛出错误以触发 catch
}
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
const ttmlLyrics = parseTTML(res).lines
if (!ttmlLyrics || ttmlLyrics.length === 0) {
throw new Error('TTML 解析为空') // 抛出错误以触发 catch
}
// --- TTML 成功 ---
parsedLyrics = ttmlLyrics
// 此时我们不再关心 SDK 的结果
// 为防止 sdkPromise 失败时出现 "unhandled rejection"
// 我们给它加一个空的 catch 来“静音”它的潜在错误。
sdkPromise.catch(() => {
/* TTML 优先,忽略 SDK 的错误 */
})
} catch (ttmlError: any) {
// --- TTML 失败,回退到 SDK ---
// 检查是否是因为中止操作
if (!active || (ttmlError && ttmlError.name === 'AbortError')) {
return
}
// console.log('TTML failed, falling back to SDK:', ttmlError.message);
try {
// 现在等待已经启动的 SDK 请求
const sdkLyrics = await sdkPromise
if (sdkLyrics) {
parsedLyrics = sdkLyrics
} else {
// SDK 也失败了或没有返回歌词
// console.log('SDK fallback also provided no lyrics.');
parsedLyrics = [] // 或者保持原样
}
} catch (sdkError) {
// TTML 和 SDK 都失败了
// console.error('Both TTML and SDK failed:', { ttmlError, sdkError });
parsedLyrics = [] // 最终回退
}
}
} else {
} else if (source !== 'local') {
// 其他来源:直接统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source,
@@ -216,6 +280,13 @@ watch(
}
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
} else {
const text = (props.songInfo as any).lrc as string | null
if (text && (/^\[(\d+),\d+\]/.test(text) || /\(\d+,\d+,\d+\)/.test(text))) {
parsedLyrics = text ? (parseYrc(text) as any) : []
} else {
parsedLyrics = text ? (parseLrc(text) as any) : []
}
}
if (!active) return
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
@@ -229,71 +300,6 @@ watch(
{ immediate: true }
)
// 桌面歌词联动:构建歌词负载、计算当前行并通过 IPC 推送
const buildLyricPayload = (lines: LyricLine[]) =>
(lines || []).map((l) => ({
content: (l.words || []).map((w) => w.word).join(''),
tran: l.translatedLyric || ''
}))
const lastLyricIndex = ref(-1)
const computeLyricIndex = (timeMs: number, lines: LyricLine[]) => {
if (!lines || lines.length === 0) return -1
const t = timeMs
const i = lines.findIndex((l) => t >= l.startTime && t < l.endTime)
if (i !== -1) return i
for (let j = lines.length - 1; j >= 0; j--) {
if (t >= lines[j].startTime) return j
}
return -1
}
// 歌词集合变化时先推一次集合index 为 -1由窗口自行处理占位
watch(
() => state.lyricLines,
(lines) => {
const payload = { index: -1, lyric: buildLyricPayload(lines) }
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload)
},
{ deep: true, immediate: true }
)
// 当前时间变化时,计算当前行并推送
watch(
() => state.currentTime,
(ms) => {
const idx = computeLyricIndex(ms, state.lyricLines)
if (idx !== lastLyricIndex.value) {
lastLyricIndex.value = idx
const payload = { index: idx, lyric: buildLyricPayload(state.lyricLines) }
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', payload)
}
}
)
// 播放状态推送(用于窗口播放/暂停按钮联动)
watch(
() => Audio.value.isPlay,
(playing) => {
;(window as any)?.electron?.ipcRenderer?.send?.('play-status-change', playing)
},
{ immediate: true }
)
// 歌曲标题推送
watch(
() => props.songInfo,
(info) => {
try {
const name = (info as any)?.name || ''
const artist = (info as any)?.singer || ''
const title = [name, artist].filter(Boolean).join(' - ')
if (title) (window as any)?.electron?.ipcRenderer?.send?.('play-song-change', title)
} catch {}
},
{ immediate: true, deep: true }
)
const bgRef = ref<BackgroundRenderRef | undefined>(undefined)
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
@@ -489,7 +495,7 @@ const lyricTranslateY = computed(() => {
<div v-show="state.lyricLines.length > 0" class="right">
<LyricPlayer
ref="lyricPlayerRef"
:lyric-lines="props.show ? state.lyricLines : []"
:lyric-lines="props.show ? toRaw(state.lyricLines) : []"
:current-time="state.currentTime"
class="lyric-player"
:enable-spring="playSetting.getisJumpLyric"

View File

@@ -103,77 +103,6 @@ const toggleDesktopLyric = async () => {
console.error('切换桌面歌词失败:', e)
}
}
// 监听来自主进程的锁定状态广播
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
desktopLyricLocked.value = !!lock
})
// 监听主进程通知关闭桌面歌词
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
desktopLyricOpen.value = false
desktopLyricLocked.value = false
})
window.addEventListener('global-music-control', (e: any) => {
const name = e?.detail?.name
console.log(name);
switch (name) {
case 'play':
handlePlay()
break
case 'pause':
handlePause()
break
case 'playPrev':
playPrevious()
break
case 'playNext':
playNext()
break
}
})
document.addEventListener('keydown', KeyEvent)
// 处理最小化右键的事件
const removeMusicCtrlListener = window.api.onMusicCtrl(() => {
togglePlayPause()
})
let timer: any = null
function throttle(callback: Function, delay: number) {
if (timer) return
timer = setTimeout(() => {
callback()
timer = null
}, delay)
}
function KeyEvent(e: KeyboardEvent) {
throttle(() => {
if (e.code == 'Space' && showFullPlay.value) {
e.preventDefault()
togglePlayPause()
} else if (e.code == 'ArrowUp') {
e.preventDefault()
console.log('up')
controlAudio.setVolume(Audio.value.volume + 5)
} else if (e.code == 'ArrowDown') {
e.preventDefault()
console.log('down')
controlAudio.setVolume(Audio.value.volume - 5)
} else if (e.code == 'ArrowLeft' && Audio.value.audio && Audio.value.audio.currentTime >= 0) {
Audio.value.audio.currentTime -= 5
} else if (
e.code == 'ArrowRight' &&
Audio.value.audio &&
Audio.value.audio.currentTime <= Audio.value.audio.duration
) {
console.log('right')
Audio.value.audio.currentTime += 5
}
}, 100)
}
// 等待音频准备就绪
const waitForAudioReady = (): Promise<void> => {
return new Promise((resolve, reject) => {
@@ -222,7 +151,7 @@ let pendingRestorePosition = 0
let pendingRestoreSongId: number | string | null = null
// 记录组件被停用前的播放状态
let wasPlaying = false
// let wasPlaying = false
// let playbackPosition = 0
let isFull = false
@@ -567,41 +496,33 @@ const playNext = async () => {
// 定期保存当前播放位置
let savePositionInterval: number | null = null
let unEnded: () => any = () => {}
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 (Audio.value.audio) {
mediaSessionController.init(Audio.value.audio, {
play: async () => {
// 专门的播放函数,只处理播放逻辑
if (!Audio.value.isPlay) {
await handlePlay()
}
},
pause: async () => {
// 专门的暂停函数,只处理暂停逻辑
if (Audio.value.isPlay) {
await handlePause()
}
},
playPrevious: () => playPrevious(),
playNext: () => playNext()
})
}
// 监听音频结束事件,根据播放模式播放下一首
unEnded = controlAudio.subscribe('ended', () => {
window.requestAnimationFrame(() => {
console.log('播放结束')
playNext()
})
})
// 检查是否有上次播放的歌曲
// 检查是否有上次播放的歌曲
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
@@ -655,21 +576,34 @@ onMounted(async () => {
userInfo.value.currentTime = Audio.value.currentTime
}
}, 1000) // 每1秒保存一次
// 监听播放器事件
// TODO: 这边监听没有取消
// 监听来自主进程的锁定状态广播
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
desktopLyricLocked.value = !!lock
})
// 监听主进程通知关闭桌面歌词
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
desktopLyricOpen.value = false
desktopLyricLocked.value = false
})
window.addEventListener('global-music-control', PlayerEvent)
})
// 组件卸载时清理
onUnmounted(() => {
destroyPlaylistEventListeners()
document.removeEventListener('keydown', KeyEvent)
// 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)
}
if (removeMusicCtrlListener) {
removeMusicCtrlListener()
}
// 清理媒体会话控制器
mediaSessionController.cleanup()
unEnded()
})
// 组件被激活时(从缓存中恢复)
@@ -702,15 +636,9 @@ onActivated(async () => {
// 组件被停用时(缓存但不销毁)
onDeactivated(() => {
console.log('PlayMusic组件被停用')
// 保存当前播放状态
wasPlaying = Audio.value.isPlay
// playbackPosition = Audio.value.currentTime
// 仅记录状态,不主动暂停,避免页面切换导致音乐暂停
// wasPlaying = Audio.value.isPlay
isFull = showFullPlay.value
// 如果正在播放,暂停播放但不改变状态标志
if (wasPlaying && Audio.value.audio) {
Audio.value.audio.pause()
console.log('暂时暂停播放,状态已保存')
}
})
// 监听用户信息变化,更新音量
@@ -878,19 +806,24 @@ const handlePlay = async () => {
// 专门的暂停函数
const handlePause = async () => {
if (Audio.value.url && Audio.value.isPlay) {
const a = Audio.value.audio
if (Audio.value.url && a && !a.paused) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
// 暂停后,同步 SMTC 状态
mediaSessionController.updatePlaybackState('paused')
} else if (Audio.value.url) {
// 已处于暂停或未知状态,也同步一次 SMTC确保外部显示一致
mediaSessionController.updatePlaybackState('paused')
}
}
// 播放/暂停切换
const togglePlayPause = async () => {
if (Audio.value.isPlay) {
const a = Audio.value.audio
const isActuallyPlaying = a ? !a.paused : Audio.value.isPlay
if (isActuallyPlaying) {
await handlePause()
} else {
await handlePlay()
@@ -1304,38 +1237,37 @@ watch(showFullPlay, (val) => {
/* 进度条样式 */
.progress-bar-container {
width: 100%;
height: 4px;
--touch-range-height: 20px;
--play-line-height: 4px;
height: calc(var(--touch-range-height) + var(--play-line-height)); // 放大可点击区域,但保持视觉细
position: absolute;
// padding-top: 2px;
top: calc(var(--touch-range-height) / 2 * -1);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:has(.progress-handle.dragging, *:hover) {
// margin-bottom: 0;
height: 6px;
}
.progress-bar {
width: 100%;
height: 100%;
position: relative;
.progress-background {
// 视觉上的细轨道,垂直居中
.progress-background,
.progress-filled {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
height: var(--play-line-height);
top: 50%;
transform: translateY(-50%);
border-radius: 999px;
}
.progress-background {
background: transparent;
}
.progress-filled {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(to right, v-bind(startmaincolor), v-bind(maincolor) 80%);
border-radius: 999px;
}
.progress-handle {
@@ -1347,7 +1279,6 @@ watch(showFullPlay, (val) => {
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
// transition: opacity 0.2s ease;
&:hover,
&:active,
@@ -1356,6 +1287,20 @@ watch(showFullPlay, (val) => {
}
}
// 悬停或拖拽时,轻微加粗提升可见性
&:hover {
.progress-background,
.progress-filled {
height: 6px;
}
}
&:has(.progress-handle.dragging) {
.progress-background,
.progress-filled {
height: 6px;
}
}
&:hover .progress-handle {
opacity: 1;
}

View File

@@ -33,9 +33,14 @@ const menuList: MenuItem[] = [
icon: 'icon-faxian',
path: '/home/find'
},
{
name: '歌单',
icon: 'icon-yanchu',
path: '/home/songlist'
},
{
name: '本地',
icon: 'icon-music',
icon: 'icon-shouye',
path: '/home/local'
},
{

View File

@@ -16,6 +16,11 @@ const routes: RouteRecordRaw[] = [
name: 'find',
component: () => import('@renderer/views/music/find.vue')
},
{
path: 'songlist',
name: 'songlist',
component: () => import('@renderer/views/music/songlist.vue')
},
{
path: 'local',
name: 'local',

View File

@@ -28,8 +28,15 @@ class AudioManager {
context = new (window.AudioContext || (window as any).webkitAudioContext)()
source = context.createMediaElementSource(audioElement)
// 连接到输出,确保音频能正常播放
source.connect(context.destination)
// 确保仅通过分流器连接,避免重复直连导致音量叠加
let splitter = this.splitters.get(audioElement)
if (!splitter) {
splitter = context.createGain()
splitter.gain.value = 1.0
source.connect(splitter)
splitter.connect(context.destination)
this.splitters.set(audioElement, splitter)
}
// 存储引用
this.audioSources.set(audioElement, source)

View File

@@ -0,0 +1,117 @@
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import mediaSessionController from '@renderer/utils/audio/useSmtc'
let installed = false
function dispatch(name: string, val?: any) {
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
}
export function installGlobalMusicControls() {
if (installed) return
installed = true
const controlAudio = ControlAudioStore()
// 初始化 SMTC并在音频元素可用时同步一次播放状态与动作处理器
const tryInitSmtc = () => {
const el = controlAudio.Audio.audio
if (!el) return
mediaSessionController.init(el, {
play: () => dispatch('play'),
pause: () => dispatch('pause'),
playPrevious: () => dispatch('playPrev'),
playNext: () => dispatch('playNext')
})
// 初始同步状态
mediaSessionController.updatePlaybackState(el.paused ? 'paused' : 'playing')
}
// 尝试立即初始化一次
tryInitSmtc()
// 若 URL 变化或 audio 初始化稍后完成,由组件/Store 负责赋值;这里轮询几次兜底初始化
let smtcTries = 0
const smtcTimer = setInterval(() => {
if (smtcTries > 20) {
clearInterval(smtcTimer)
return
}
smtcTries++
if (controlAudio.Audio.audio) {
tryInitSmtc()
clearInterval(smtcTimer)
}
}, 150)
let keyThrottle = false
const throttle = (cb: () => void, delay: number) => {
if (keyThrottle) return
keyThrottle = true
setTimeout(() => {
try {
cb()
} finally {
keyThrottle = false
}
}, delay)
}
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
const tag = (target?.tagName || '').toLowerCase()
const isEditable =
target?.hasAttribute('contenteditable') ||
tag === 'input' ||
tag === 'textarea' ||
(target as HTMLInputElement)?.type === 'text' ||
(target as HTMLInputElement)?.type === 'search' ||
(target as HTMLInputElement)?.type === 'password' ||
(target as HTMLInputElement)?.type === 'email' ||
(target as HTMLInputElement)?.type === 'url' ||
(target as HTMLInputElement)?.type === 'number'
throttle(() => {
if (e.code === 'Space') {
if (isEditable) return
e.preventDefault()
dispatch('toggle')
} else if (e.code === 'ArrowUp') {
e.preventDefault()
controlAudio.setVolume(controlAudio.Audio.volume + 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
}
}, 100)
}
document.addEventListener('keydown', onKeyDown)
// 监听音频结束事件,根据播放模式播放下一首
controlAudio.subscribe('ended', () => {
window.requestAnimationFrame(() => {
console.log('播放结束')
dispatch('playNext')
})
})
// 托盘或系统快捷键回调(若存在)
try {
const removeMusicCtrlListener = (window as any).api?.onMusicCtrl?.(() => {
dispatch('toggle')
})
void removeMusicCtrlListener
} catch {
// ignore
}
}

View File

@@ -0,0 +1,229 @@
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import _ from 'lodash'
import { toRaw } from 'vue'
import {
parseYrc,
parseLrc,
parseTTML,
parseQrc
} from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
interface LyricWord {
word: string
}
interface LyricLine {
startTime: number
endTime: number
words: LyricWord[]
translatedLyric?: string
}
let installed = false
function buildLyricPayload(lines: LyricLine[]) {
return (lines || []).map((l) => ({
content: (l.words || []).map((w) => w.word).join(''),
tran: l.translatedLyric || ''
}))
}
function computeLyricIndex(timeMs: number, lines: LyricLine[]) {
if (!lines || lines.length === 0) return -1
const t = timeMs
const i = lines.findIndex((l) => t >= l.startTime && t < l.endTime)
if (i !== -1) return i
for (let j = lines.length - 1; j >= 0; j--) {
if (t >= lines[j].startTime) return j
}
return -1
}
function mergeTranslation(base: LyricLine[], tlyric?: string): LyricLine[] {
if (!tlyric || base.length === 0) return base
const translated = parseLrc(tlyric) as LyricLine[]
if (!translated || translated.length === 0) return base
const joinWords = (line: LyricLine) => (line.words || []).map((w) => w.word).join('')
const translatedSorted = translated.slice().sort((a, b) => a.startTime - b.startTime)
const baseTolerance = 300
const ratioTolerance = 0.4
if (base.length > 0) {
const firstBase = base[0]
const firstDuration = Math.max(1, firstBase.endTime - firstBase.startTime)
const firstTol = Math.min(baseTolerance, firstDuration * ratioTolerance)
let anchorIndex: number | null = null
let bestDiff = Number.POSITIVE_INFINITY
for (let i = 0; i < translatedSorted.length; i++) {
const diff = Math.abs(translatedSorted[i].startTime - firstBase.startTime)
if (diff <= firstTol && diff < bestDiff) {
bestDiff = diff
anchorIndex = i
}
}
if (anchorIndex !== null) {
let j = anchorIndex
for (let i = 0; i < base.length && j < translatedSorted.length; i++, j++) {
const bl = base[i]
const tl = translatedSorted[j]
if (!tl?.words?.[0]?.word || tl.words[0].word === '//' || !bl?.words?.[0]?.word) continue
const text = joinWords(tl)
if (text) bl.translatedLyric = text
}
return base
}
}
return base
}
export function installDesktopLyricBridge() {
if (installed) return
installed = true
const controlAudio = ControlAudioStore()
const localUser = LocalUserDetailStore()
let currentLines: LyricLine[] = []
let lastIndex = -1
let abortCtrl: AbortController | null = null
async function fetchLyricsForCurrentSong() {
const songId = localUser.userInfo.lastPlaySongId
if (!songId) {
currentLines = []
lastIndex = -1
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', { index: -1, lyric: [] })
return
}
const song = localUser.list.find((s: any) => s.songmid === songId)
if (!song) {
currentLines = []
lastIndex = -1
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', { index: -1, lyric: [] })
return
}
// 取消上一轮请求
if (abortCtrl) abortCtrl.abort()
abortCtrl = new AbortController()
const source = (song as any).source || 'kg'
let parsed: LyricLine[] = []
try {
if (source === 'wy' || source === 'tx') {
try {
const res = await (
await fetch(
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${songId}`,
{ signal: abortCtrl.signal }
)
).text()
if (!res || res.length < 100) throw new Error('no ttml')
parsed = (parseTTML(res) as any).lines as LyricLine[]
} catch {
const lyricData = await (window as any)?.api?.music?.requestSdk?.('getLyric', {
source,
songInfo: _.cloneDeep(toRaw(song))
})
if (lyricData?.crlyric) {
parsed = (
source === 'tx' ? parseQrc(lyricData.crlyric) : parseYrc(lyricData.crlyric)
) as LyricLine[]
} else if (lyricData?.lyric) {
parsed = parseLrc(lyricData.lyric) as LyricLine[]
}
parsed = mergeTranslation(parsed, lyricData?.tlyric)
}
} else if (source !== 'local') {
const lyricData = await (window as any)?.api?.music?.requestSdk?.('getLyric', {
source,
songInfo: _.cloneDeep(toRaw(song))
})
if (lyricData?.crlyric) {
parsed = (
source === 'tx' ? parseQrc(lyricData.crlyric) : parseYrc(lyricData.crlyric)
) as LyricLine[]
} else if (lyricData?.lyric) {
parsed = parseLrc(lyricData.lyric) as LyricLine[]
}
parsed = mergeTranslation(parsed, lyricData?.tlyric)
} else {
const text = (song as any).lrc as string | null
if (text && (/^\[(\d+),\d+\]/.test(text) || /\(\d+,\d+,\d+\)/.test(text))) {
parsed = text ? (parseQrc(text) as any) : []
} else {
parsed = text ? (parseLrc(text) as any) : []
}
}
} catch (e) {
console.error('获取歌词失败:', e)
parsed = []
}
currentLines = parsed || []
lastIndex = -1
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', {
index: -1,
lyric: buildLyricPayload(currentLines)
})
// 提示前端进入准备态:先渲染 1、2 句左右铺开
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-index', -1)
// 同步歌名
try {
const name = (song as any)?.name || ''
const artist = (song as any)?.singer || ''
const title = [name, artist].filter(Boolean).join(' - ')
if (title) (window as any)?.electron?.ipcRenderer?.send?.('play-song-change', title)
} catch {}
}
// 监听歌曲切换
let lastSongId: any = undefined
setInterval(() => {
if (localUser.userInfo.lastPlaySongId !== lastSongId) {
lastSongId = localUser.userInfo.lastPlaySongId
fetchLyricsForCurrentSong()
}
}, 300)
// 播放状态推送
let lastPlayState: any = undefined
setInterval(() => {
if (controlAudio.Audio.isPlay !== lastPlayState) {
lastPlayState = controlAudio.Audio.isPlay
;(window as any)?.electron?.ipcRenderer?.send?.('play-status-change', lastPlayState)
}
}, 300)
// 时间推进与当前行/进度推送
setInterval(() => {
const a = controlAudio.Audio
const ms = Math.round((a?.currentTime || 0) * 1000)
const idx = computeLyricIndex(ms, currentLines)
// 计算当前行进度0~1
let progress = 0
if (idx >= 0 && currentLines[idx]) {
const line = currentLines[idx]
const dur = Math.max(1, (line.endTime ?? line.startTime + 1) - line.startTime)
progress = Math.min(1, Math.max(0, (ms - line.startTime) / dur))
}
// 首先推送进度,便于前端做 30% 判定(避免 setTimeout 带来的抖动)
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-progress', {
index: idx,
progress
})
// 当行变化时,推送 index立即切换高亮并附带完整歌词集合仅在变化时下发减少开销
if (idx !== lastIndex) {
lastIndex = idx
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-index', idx)
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', {
index: idx,
lyric: buildLyricPayload(currentLines)
})
}
}, 100)
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,13 @@ const qualityKey = Object.keys(qualityMap)
*/
export async function getSongRealUrl(song: SongList): Promise<string> {
try {
if ((song as any).source === 'local') {
const id = (song as any).songmid
const url = await (window as any).api.localMusic.getUrlById(id)
if (typeof url === 'object' && url?.error) throw new Error(url.error)
if (typeof url === 'string') return url
throw new Error('本地歌曲URL获取失败')
}
// 获取当前用户的信息
const LocalUserDetail = LocalUserDetailStore()
// 通过统一的request方法获取真实的播放URL

View File

@@ -81,20 +81,7 @@ const fetchLocalPlaylistSongs = async () => {
const result = await window.api.songList.getSongs(playlistInfo.value.id)
if (result.success && result.data) {
songs.value = result.data.map((song: any) => ({
singer: song.singer || '未知歌手',
name: song.name || '未知歌曲',
albumName: song.albumName || '未知专辑',
albumId: song.albumId || 0,
source: song.source || 'local',
interval: song.interval || '0:00',
songmid: song.songmid,
img: song.img || '',
lrc: song.lrc || null,
types: song.types || [],
_types: song._types || {},
typeUrl: song.typeUrl || {}
}))
songs.value = result.data
// 更新歌单信息中的歌曲总数
playlistInfo.value.total = songs.value.length

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,7 @@ const formatPlayTime = (timeStr: string): string => {
<template>
<div class="recent-container">
<div>待开发 作者正在麻溜赶代码 奈何还有期中考 不要抽我呀</div>
<!-- 页面标题和操作 -->
<div class="page-header">
<div class="header-left">

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,9 @@
:root {
--font-size: 30;
--main-color: #73BCFC;
--main-color: #73bcfc;
--shadow-color: rgba(255, 255, 255, 0.5);
--next-color: rgba(255, 255, 255, 0.68);
}
body {
@@ -166,6 +167,7 @@
z-index: 1;
max-width: 100%;
pointer-events: auto;
width: 100%;
#lyric-text {
font-size: calc(var(--font-size) * 1px);
@@ -199,6 +201,75 @@
transform: translateX(-100%);
}
}
/* 双行交错歌词布局 */
.lines-root {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
}
.line {
display: flex;
flex-direction: column;
width: 100%;
transition: transform 0.35s ease, opacity 0.35s ease;
will-change: transform, opacity;
}
.line *{
transition:
transform 0.35s ease,
opacity 0.35s ease;
will-change: transform, opacity;
text-shadow: 0 0 4px var(--shadow-color);
}
.line .text {
font-size: calc(var(--font-size) * 1px);
font-weight: 700;
}
.line .tran {
font-size: calc(var(--font-size) * 1px - 5px);
opacity: 0.6;
margin-top: 6px;
}
.line.left {
align-items: flex-start;
text-align: left;
}
.line.right {
align-items: flex-end;
text-align: right;
}
/* 当前句 vs 下一句 颜色区分 */
.line.current .text {
color: var(--main-color);
opacity: 1;
}
.line.current .tran {
opacity: 0.8;
}
.line.upnext .text {
color: var(--next-color);
opacity: 0.65;
}
.line.upnext .tran {
opacity: 0.45;
}
/* 进入动画:使用关键帧,避免过渡时序问题 */
@keyframes lyricEnter {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* 备用:如果需要离场可再启用 */
@keyframes lyricLeave {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
</style>
</head>
@@ -309,8 +380,18 @@
</div>
</header>
<main id="lyric-content">
<span id="lyric-text">该歌曲暂无歌词</span>
<span id="lyric-tran"></span>
<span id="lyric-text" style="display: none">该歌曲暂无歌词</span>
<span id="lyric-tran" style="display: none"></span>
<div id="lines-root" class="lines-root">
<div class="line line-a left">
<div class="text"></div>
<div class="tran"></div>
</div>
<div class="line line-b right">
<div class="text"></div>
<div class="tran"></div>
</div>
</div>
</main>
<script>
class LyricsWindow {
@@ -333,6 +414,12 @@
this.winHeight = 0
// 临时变量
// this.lyricIndex = -1;
// 每首歌起始基准索引,用于保证第一句总在左侧
this._baseIndex = null
// 上一索引与延迟更新队列(仅延迟“上一行->下一行”的替换)
this._lastIndex = -1
this._pendingUpnext = null // { wrapEl, textEl, tranEl, data }
this._lastLyricPayload = { lyric: [] }
// 初始化
this.restoreOptions()
this.menuClick()
@@ -340,14 +427,123 @@
this.setupWindowDragListeners()
this.setupMutationObserver()
}
// 歌词切换动画
updateLyrics(content = '纯音乐,请欣赏', translation = '') {
// document.startViewTransition(() => {
// this.lyricTextDom.innerHTML = content;
// this.lyricTranDom.innerHTML = translation;
// });
this.lyricTextDom.innerHTML = content
this.lyricTranDom.innerHTML = translation
// 准备态渲染:展示第 1、2 句左右铺开,不标记 current/upnext
renderPrepare() {
const payload = this._lastLyricPayload || { lyric: [] }
const lines = payload.lyric || []
if (!Array.isArray(lines) || lines.length === 0) return
const first = lines[0] || { content: '', tran: '' }
const second = lines[1] || { content: '', tran: '' }
const root = document.getElementById('lines-root')
const lineA = root.querySelector('.line-a')
const lineB = root.querySelector('.line-b')
const aText = lineA.querySelector('.text')
const aTran = lineA.querySelector('.tran')
const bText = lineB.querySelector('.text')
const bTran = lineB.querySelector('.tran')
lineA.classList.remove('current', 'upnext')
lineB.classList.remove('current', 'upnext')
const updateSide = (wrapEl, textEl, tranEl, data) => {
const newText = data?.content || ''
const newTran = data?.tran || ''
if (textEl.textContent !== newText || tranEl.textContent !== newTran) {
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
}
}
updateSide(lineA, aText, aTran, first)
updateSide(lineB, bText, bTran, second)
}
// 基于当前行索引的“AB 交错 + 预显下一句”规则:
// 当前 index 偶数 -> 左=当前句(index),右=下一句(index+1)
// 当前 index 奇数 -> 左=下一句(index+1),右=当前句(index)
renderByIndex(index) {
const payload = this._lastLyricPayload || { lyric: [] }
const lines = payload.lyric || []
if (!Array.isArray(lines) || lines.length === 0) return
if (typeof index !== 'number' || index < 0 || index >= lines.length) return
// 锁定基准:确保每首歌的第一句渲染在左侧
if (this._baseIndex === null) this._baseIndex = index
const cur = lines[index] || { content: '纯音乐,请欣赏', tran: '' }
const next = lines[index + 1] || { content: '', tran: '' }
const root = document.getElementById('lines-root')
const lineA = root.querySelector('.line-a')
const lineB = root.querySelector('.line-b')
const aText = lineA.querySelector('.text')
const aTran = lineA.querySelector('.tran')
const bText = lineB.querySelector('.text')
const bTran = lineB.querySelector('.tran')
const even = (index - this._baseIndex) % 2 === 0
// 标记当前句与下一句以应用不同颜色样式(立即切换高亮,不等 30%
lineA.classList.remove('current', 'upnext')
lineB.classList.remove('current', 'upnext')
let currentWrap, currentText, currentTran, upnextWrap, upnextText, upnextTran
if (even) {
// 左:当前;右:下一
lineA.classList.add('current')
lineB.classList.add('upnext')
currentWrap = lineA
currentText = aText
currentTran = aTran
upnextWrap = lineB
upnextText = bText
upnextTran = bTran
} else {
// 左:下一;右:当前
lineA.classList.add('upnext')
lineB.classList.add('current')
currentWrap = lineB
currentText = bText
currentTran = bTran
upnextWrap = lineA
upnextText = aText
upnextTran = aTran
}
const updateSide = (wrapEl, textEl, tranEl, data) => {
const newText = data?.content || ''
const newTran = data?.tran || ''
const oldText = textEl.textContent || ''
const oldTran = tranEl.textContent || ''
if (newText === oldText && newTran === oldTran) return
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
}
// 1) 立即更新“当前句”内容,保证开始朗读就高亮且正确
updateSide(currentWrap, currentText, currentTran, cur)
// 2) 对“上一行 -> 下一行”的替换延迟到 30% 进度
const desiredUpText = next?.content || ''
const desiredUpTran = next?.tran || ''
const upOldText = upnextText.textContent || ''
const upOldTran = upnextTran.textContent || ''
// 若 upnext 目前已是下一句,则无需延迟
if (desiredUpText === upOldText && desiredUpTran === upOldTran) {
this._pendingUpnext = null
} else {
// 仅记录待更新,不立刻替换
this._pendingUpnext = {
index,
wrapEl: upnextWrap,
textEl: upnextText,
tranEl: upnextTran,
data: next
}
}
}
// 获取配置
async restoreOptions() {
@@ -365,9 +561,18 @@
changeOptions(options, callback = true) {
if (!options) return
const { fontSize, mainColor, shadowColor } = options
document.documentElement.style.setProperty('--font-size', fontSize)
document.documentElement.style.setProperty('--main-color', mainColor)
document.documentElement.style.setProperty('--shadow-color', shadowColor)
const root = document.documentElement
root.style.setProperty('--font-size', fontSize)
root.style.setProperty('--main-color', mainColor)
root.style.setProperty('--shadow-color', shadowColor)
// 基于主色生成灰度化的 next 颜色(使用 color-mix 提升兼容/观感)
try {
const next = `color-mix(in srgb, ${mainColor} 35%, white 65%)`
root.style.setProperty('--next-color', next)
} catch (e) {
// 退化:若不支持 color-mix则使用固定透明度的主色
root.style.setProperty('--next-color', mainColor)
}
if (callback) window.electron.ipcRenderer.send('set-desktop-lyric-option', options)
}
// 菜单点击事件
@@ -442,6 +647,8 @@
const [songName, songArtist] = title.split(' - ')
this.songNameDom.innerHTML = songName
this.songArtistDom.innerHTML = songArtist
// 每首歌切换时重置基准索引,确保首句在左侧
this._baseIndex = null
this.updateLyrics(title)
})
@@ -450,11 +657,55 @@
this.parsedLyricsData(lyricData)
})
// 接收当前行索引(若主进程/渲染端已推送 index并按奇偶切换 A/B 行,避免“右侧唱完才换左侧”的滞后问题
window.electron.ipcRenderer.on('play-lyric-index', (_, index) => {
this._lastIndex = this._lastIndex ?? -1
if (typeof index !== 'number' || index === this._lastIndex) return
// 准备态index === -1先展示第 1、2 句左右铺开(不打标 current/upnext
if (index === -1) {
this._baseIndex = null
this._lastIndex = index
this.renderPrepare()
return
}
// 第一次收到该首歌的有效 index锁定基准保证奇偶性从 index 开始算起时首句落在左侧
if (this._baseIndex === null) this._baseIndex = index
this._lastIndex = index
this.renderByIndex(index)
})
window.electron.ipcRenderer.on('play-status-change', (_, status) => {
this.playDom.classList.toggle('hidden', status)
this.pauseDom.classList.toggle('hidden', !status)
})
// 进度事件:用于 30% 后再替换上一侧为“下一句”
window.electron.ipcRenderer.on('play-lyric-progress', (_, payload) => {
try {
const { index, progress } = payload || {}
if (!this._pendingUpnext) return
// 仅当仍在同一行朗读时触发
if (typeof index !== 'number' || index !== this._pendingUpnext.index) return
if (typeof progress !== 'number' || progress < 0.3) return
const { wrapEl, textEl, tranEl, data } = this._pendingUpnext
const newText = data?.content || ''
const newTran = data?.tran || ''
if (textEl.textContent === newText && tranEl.textContent === newTran) {
this._pendingUpnext = null
return
}
// 执行替换与入场动画
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
this._pendingUpnext = null
} catch {}
})
// 配置变化
window.electron.ipcRenderer.on('desktop-lyric-option-change', (_, options) => {
this.changeOptions(options, false)
@@ -469,12 +720,17 @@
parsedLyricsData(lyricData) {
if (!this.lyricContentDom || !this.lyricTextDom) return
const { index, lyric } = lyricData
// 更换文字
if (!lyric || index < 0) {
if (lyric.length === 0) this.updateLyrics()
} else {
const { content, tran } = lyric[index]
this.updateLyrics(content, tran || '')
// 缓存全集合,等待 index 推送来决定显示侧(奇偶)
this._lastLyricPayload = { lyric }
if (!lyric || lyric.length === 0) {
this.updateLyrics()
return
}
// 如果带 index则立即按规则渲染否则等待 play-lyric-index桥接侧也会周期推 index
if (typeof index === 'number' && index >= 0 && index < lyric.length) {
this.renderByIndex(index)
}
}
// 拖拽窗口