mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 03:15:07 +08:00
fix: 酷狗歌单无法播放,软件无法退出,全局播放进入设置无法下一曲 feat:本地播放
This commit is contained in:
@@ -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
692
playlist-converter.html
Normal 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>
|
||||
9
src/common/types/localSongs.ts
Normal file
9
src/common/types/localSongs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default interface localList {
|
||||
singer: string
|
||||
name: string
|
||||
albumName: string
|
||||
interval: string
|
||||
duration: string
|
||||
img: string
|
||||
lrc: null | string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
334
src/main/events/localMusic.ts
Normal file
334
src/main/events/localMusic.ts
Normal 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 || '清空失败' }
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
113
src/main/services/LocalMusicIndex.ts
Normal file
113
src/main/services/LocalMusicIndex.ts
Normal 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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 创建主窗口
|
||||
|
||||
10
src/preload/index.d.ts
vendored
10
src/preload/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
src/renderer/components.d.ts
vendored
5
src/renderer/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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 } }))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
117
src/renderer/src/utils/audio/globalControls.ts
Normal file
117
src/renderer/src/utils/audio/globalControls.ts
Normal 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
|
||||
}
|
||||
}
|
||||
229
src/renderer/src/utils/lyrics/desktopLyricBridge.ts
Normal file
229
src/renderer/src/utils/lyrics/desktopLyricBridge.ts
Normal 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)
|
||||
}
|
||||
4202
src/renderer/src/utils/playlist/lx_list_part_list__name_default.json
Normal file
4202
src/renderer/src/utils/playlist/lx_list_part_list__name_default.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -112,6 +112,7 @@ const formatPlayTime = (timeStr: string): string => {
|
||||
|
||||
<template>
|
||||
<div class="recent-container">
|
||||
<div>待开发 作者正在麻溜赶代码 奈何还有期中考 不要抽我呀!</div>
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
|
||||
2139
src/renderer/src/views/music/songlist.vue
Normal file
2139
src/renderer/src/views/music/songlist.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
// 拖拽窗口
|
||||
|
||||
Reference in New Issue
Block a user