mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 11:29:42 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9545f32c81 | ||
|
|
7945108243 | ||
|
|
6ce05d286f | ||
|
|
8209d021de | ||
|
|
a277cb7181 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ceru-music",
|
||||
"version": "1.4.7",
|
||||
"version": "1.5.0",
|
||||
"description": "一款简洁优雅的音乐播放器",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "sqj,wldss,star",
|
||||
@@ -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",
|
||||
@@ -112,4 +111,4 @@
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
378
src/main/events/localMusic.ts
Normal file
378
src/main/events/localMusic.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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).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 (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const off = /^\[offset:[+-]?\d+\]$/i.exec(line.trim())
|
||||
if (off) {
|
||||
out.push(line.trim())
|
||||
continue
|
||||
}
|
||||
const 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
|
||||
}
|
||||
const 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) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export default {
|
||||
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
|
||||
// }
|
||||
// return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu`
|
||||
// https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=1&templateVersion=2&tagId=1003449727
|
||||
if (tagId == null) {
|
||||
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1`
|
||||
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
|
||||
@@ -350,4 +351,4 @@ export default {
|
||||
|
||||
// getList
|
||||
// getTags
|
||||
// getListDetail
|
||||
// getListDetail
|
||||
|
||||
@@ -19,25 +19,49 @@ export default {
|
||||
},
|
||||
body: {
|
||||
comm: {
|
||||
ct: 11,
|
||||
cv: '1003006',
|
||||
v: '1003006',
|
||||
ct: '11',
|
||||
cv: '14090508',
|
||||
v: '14090508',
|
||||
tmeAppID: 'qqmusic',
|
||||
phonetype: 'EBG-AN10',
|
||||
deviceScore: '553.47',
|
||||
devicelevel: '50',
|
||||
newdevicelevel: '20',
|
||||
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
|
||||
os_ver: '12',
|
||||
phonetype: '0',
|
||||
devicelevel: '31',
|
||||
tmeAppID: 'qqmusiclight',
|
||||
nettype: 'NETWORK_WIFI',
|
||||
OpenUDID: '0',
|
||||
OpenUDID2: '0',
|
||||
QIMEI36: '0',
|
||||
udid: '0',
|
||||
chid: '0',
|
||||
aid: '0',
|
||||
oaid: '0',
|
||||
taid: '0',
|
||||
tid: '0',
|
||||
wid: '0',
|
||||
uid: '0',
|
||||
sid: '0',
|
||||
modeSwitch: '6',
|
||||
teenMode: '0',
|
||||
ui_mode: '2',
|
||||
nettype: '1020',
|
||||
v4ip: '',
|
||||
},
|
||||
req: {
|
||||
module: 'music.search.SearchCgiService',
|
||||
method: 'DoSearchForQQMusicLite',
|
||||
method: 'DoSearchForQQMusicMobile',
|
||||
param: {
|
||||
query: str,
|
||||
search_type: 0,
|
||||
num_per_page: limit,
|
||||
query: str,
|
||||
page_num: page,
|
||||
num_per_page: limit,
|
||||
highlight: 0,
|
||||
nqc_flag: 0,
|
||||
multi_zhida: 0,
|
||||
cat: 2,
|
||||
grp: 1,
|
||||
sin: 0,
|
||||
sem: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -159,4 +183,4 @@ export default {
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,29 @@ 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) {
|
||||
|
||||
6
src/renderer/components.d.ts
vendored
6
src/renderer/components.d.ts
vendored
@@ -20,9 +20,15 @@ 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']
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
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,12 +31,22 @@ onMounted(() => {
|
||||
syncNaiveTheme()
|
||||
window.addEventListener('theme-changed', () => syncNaiveTheme())
|
||||
|
||||
// 全局键盘/托盘播放控制安装(解耦出组件)
|
||||
import('@renderer/utils/audio/globalControls')
|
||||
.then((m) => m.installGlobalMusicControls())
|
||||
.catch(() => {})
|
||||
import('@renderer/utils/audio/globaPlayList').then((m) => m.initPlayback?.()).catch(() => {})
|
||||
import('@renderer/utils/lyrics/desktopLyricBridge')
|
||||
.then((m) => m.installDesktopLyricBridge())
|
||||
.catch(() => {})
|
||||
|
||||
// 全局监听来自主进程的播放控制事件,确保路由切换也可响应
|
||||
const forward = (name: string, val?: any) => {
|
||||
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
|
||||
}
|
||||
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
|
||||
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
|
||||
window.electron?.ipcRenderer?.on?.('toggle', () => forward('toggle'))
|
||||
window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev'))
|
||||
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
|
||||
|
||||
|
||||
@@ -304,8 +304,8 @@
|
||||
/* TitleBarControls 组件专用变量 - 亮色主题 */
|
||||
--titlebar-icon-color: #111827;
|
||||
--titlebar-icon-hover: #111827;
|
||||
--titlebar-btn-hover-bg: #f3f4f6;
|
||||
--titlebar-close-hover-bg: #fee2e2;
|
||||
--titlebar-btn-hover-bg: #00000041;
|
||||
--titlebar-close-hover-bg: #ffa1a176;
|
||||
--titlebar-close-hover-color: #dc2626;
|
||||
|
||||
/* Settings 页面专用变量 - 亮色主题 */
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div class="song-virtual-list">
|
||||
<!-- 表头 -->
|
||||
<div class="list-header">
|
||||
<div v-if="showIndex" class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div v-if="showAlbum" class="col-album">专辑</div>
|
||||
<div class="col-like">喜欢</div>
|
||||
<div v-if="showDuration" class="col-duration">时长</div>
|
||||
<div class="list-header-container" style="background-color: var(--song-list-header-bg)">
|
||||
<div class="list-header" :style="{ marginRight: hasScroll ? '10px' : '0' }">
|
||||
<div v-if="showIndex" class="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
<div v-if="showAlbum" class="col-album">专辑</div>
|
||||
<div class="col-like">喜欢</div>
|
||||
<div v-if="showDuration" class="col-duration">时长</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟滚动容器 -->
|
||||
@@ -201,6 +203,13 @@ const contextMenuSong = ref<Song | null>(null)
|
||||
// 歌单列表
|
||||
const playlists = ref<SongList[]>([])
|
||||
|
||||
const hasScroll = computed(() => {
|
||||
// 判断是否有滚动条
|
||||
return !!(
|
||||
scrollContainer.value && scrollContainer.value.scrollHeight > scrollContainer.value.clientHeight
|
||||
)
|
||||
})
|
||||
|
||||
// 计算总高度
|
||||
const totalHeight = computed(() => props.songs.length * itemHeight)
|
||||
|
||||
@@ -564,8 +573,8 @@ onUnmounted(() => {
|
||||
|
||||
.list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||
padding: 8px 20px;
|
||||
grid-template-columns: 50px 1fr 200px 60px 80px;
|
||||
padding: 8px 10px;
|
||||
background: var(--song-list-header-bg);
|
||||
border-bottom: 1px solid var(--song-list-header-border);
|
||||
font-size: 12px;
|
||||
@@ -583,7 +592,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.col-title {
|
||||
padding-left: 20px;
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -630,8 +639,8 @@ onUnmounted(() => {
|
||||
|
||||
.song-item {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
||||
padding: 8px 20px;
|
||||
grid-template-columns: 50px 1fr 200px 60px 80px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--song-list-item-border);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -664,7 +673,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
width: 100%;
|
||||
|
||||
.track-number {
|
||||
font-size: 14px;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
toRaw,
|
||||
provide
|
||||
toRaw
|
||||
} from 'vue'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
@@ -23,11 +22,17 @@ import { getBestContrastTextColorWithOpacity } from '@renderer/utils/color/contr
|
||||
import { PlayMode, type SongList } from '@renderer/types/audio'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import {
|
||||
initPlaylistEventListeners,
|
||||
destroyPlaylistEventListeners,
|
||||
getSongRealUrl
|
||||
} from '@renderer/utils/playlist/playlistManager'
|
||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||
songInfo,
|
||||
playNext,
|
||||
playPrevious,
|
||||
updatePlayMode,
|
||||
togglePlayPause,
|
||||
isLoadingSong,
|
||||
setVolume,
|
||||
seekTo,
|
||||
playSong,
|
||||
playMode
|
||||
} from '@renderer/utils/audio/globaPlayList'
|
||||
import defaultCoverImg from '/default-cover.png'
|
||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||
import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next'
|
||||
@@ -38,7 +43,7 @@ const controlAudio = ControlAudioStore()
|
||||
const localUserStore = LocalUserDetailStore()
|
||||
const { Audio } = storeToRefs(controlAudio)
|
||||
const { list, userInfo } = storeToRefs(localUserStore)
|
||||
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
||||
const {} = controlAudio
|
||||
|
||||
// 当前歌曲是否已在“我的喜欢”
|
||||
const likeState = ref(false)
|
||||
@@ -103,298 +108,15 @@ 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) => {
|
||||
const audio = Audio.value.audio
|
||||
if (!audio) {
|
||||
reject(new Error('音频元素未初始化'))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果音频已经准备就绪
|
||||
if (audio.readyState >= 3) {
|
||||
// HAVE_FUTURE_DATA
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载超时'))
|
||||
}, 10000) // 10秒超时
|
||||
|
||||
const onCanPlay = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载失败'))
|
||||
}
|
||||
|
||||
// 监听事件
|
||||
audio.addEventListener('canplay', onCanPlay, { once: true })
|
||||
audio.addEventListener('error', onError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
// 存储待恢复的播放位置
|
||||
let pendingRestorePosition = 0
|
||||
let pendingRestoreSongId: number | string | null = null
|
||||
// 播放位置恢复逻辑由全局播放管理器处理
|
||||
|
||||
// 记录组件被停用前的播放状态
|
||||
let wasPlaying = false
|
||||
// let wasPlaying = false
|
||||
|
||||
// let playbackPosition = 0
|
||||
let isFull = false
|
||||
|
||||
// 播放指定歌曲
|
||||
const playSong = async (song: SongList) => {
|
||||
try {
|
||||
// 设置加载状态
|
||||
isLoadingSong.value = true
|
||||
|
||||
// 检查是否需要恢复播放位置(历史播放)
|
||||
const isHistoryPlay =
|
||||
song.songmid === userInfo.value.lastPlaySongId &&
|
||||
userInfo.value.currentTime !== undefined &&
|
||||
userInfo.value.currentTime > 0
|
||||
|
||||
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = song.songmid
|
||||
console.log(`准备恢复播放位置: ${pendingRestorePosition}秒`)
|
||||
// 清除历史位置,避免重复恢复
|
||||
userInfo.value.currentTime = 0
|
||||
} else {
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
|
||||
// 立刻暂停当前播放 - 不等待渐变
|
||||
if (Audio.value.isPlay && Audio.value.audio) {
|
||||
Audio.value.isPlay = false
|
||||
Audio.value.audio.pause()
|
||||
// 恢复音量,避免下次播放音量为0
|
||||
Audio.value.audio.volume = Audio.value.volume / 100
|
||||
}
|
||||
|
||||
// 立刻更新 UI 到新歌曲
|
||||
songInfo.value.name = song.name
|
||||
songInfo.value.singer = song.singer
|
||||
songInfo.value.albumName = song.albumName
|
||||
songInfo.value.img = song.img
|
||||
userInfo.value.lastPlaySongId = song.songmid
|
||||
|
||||
// 如果播放列表是打开的,滚动到当前播放歌曲
|
||||
if (showPlaylist.value) {
|
||||
nextTick(() => {
|
||||
playlistDrawerRef.value?.scrollToCurrentSong()
|
||||
})
|
||||
}
|
||||
|
||||
// 更新媒体会话元数据
|
||||
mediaSessionController.updateMetadata({
|
||||
title: song.name,
|
||||
artist: song.singer,
|
||||
album: song.albumName || '未知专辑',
|
||||
artworkUrl: song.img || defaultCoverImg
|
||||
})
|
||||
|
||||
// 尝试获取 URL
|
||||
let urlToPlay = ''
|
||||
try {
|
||||
urlToPlay = await getSongRealUrl(toRaw(song))
|
||||
} catch (error: any) {
|
||||
console.error('获取歌曲 URL 失败,播放下一首原歌曲:', error)
|
||||
isLoadingSong.value = false
|
||||
tryAutoNext('获取歌曲 URL 失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 在切换前彻底重置旧音频,释放缓冲与解码器
|
||||
if (Audio.value.audio) {
|
||||
const a = Audio.value.audio
|
||||
try {
|
||||
a.pause()
|
||||
} catch {}
|
||||
a.removeAttribute('src')
|
||||
a.load()
|
||||
}
|
||||
// 设置 URL(这会触发音频重新加载)
|
||||
setUrl(urlToPlay)
|
||||
|
||||
// 等待音频准备就绪
|
||||
await waitForAudioReady()
|
||||
await setColor()
|
||||
|
||||
// 更新完整歌曲信息
|
||||
songInfo.value = { ...song }
|
||||
|
||||
/**
|
||||
* 提前关闭加载状态
|
||||
* 这样UI不会卡在“加载中”,用户能立刻看到播放键切换
|
||||
*/
|
||||
isLoadingSong.value = false
|
||||
|
||||
/**
|
||||
* 异步开始播放(不await,以免阻塞UI)
|
||||
*/
|
||||
start()
|
||||
.catch(async (error: any) => {
|
||||
console.error('启动播放失败:', error)
|
||||
tryAutoNext('启动播放失败')
|
||||
})
|
||||
.then(() => {
|
||||
autoNextCount.value = 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 注册事件监听,确保浏览器播放事件触发时同步关闭loading
|
||||
* (多一道保险)
|
||||
*/
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.addEventListener(
|
||||
'playing',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
Audio.value.audio.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('播放歌曲失败(外层捕获):', error)
|
||||
tryAutoNext('播放歌曲失败')
|
||||
// MessagePlugin.error('播放失败,原因:' + error.message)
|
||||
isLoadingSong.value = false
|
||||
} finally {
|
||||
// 最后的保险,确保加载状态一定会被关闭
|
||||
isLoadingSong.value = false
|
||||
}
|
||||
}
|
||||
|
||||
provide('PlaySong', playSong)
|
||||
// 歌曲信息
|
||||
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
|
||||
// const playMode = ref(PlayMode.SEQUENCE)
|
||||
|
||||
// 歌曲加载状态
|
||||
const isLoadingSong = ref(false)
|
||||
|
||||
// 自动下一首次数限制:不超过当前列表的30%
|
||||
const autoNextCount = ref(0)
|
||||
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
|
||||
const tryAutoNext = (reason: string) => {
|
||||
const limit = getAutoNextLimit()
|
||||
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
|
||||
|
||||
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
|
||||
MessagePlugin.error(
|
||||
`自动下一首失败:超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
|
||||
)
|
||||
return
|
||||
}
|
||||
autoNextCount.value++
|
||||
playNext()
|
||||
}
|
||||
|
||||
// 更新播放模式
|
||||
const updatePlayMode = () => {
|
||||
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
|
||||
const currentIndex = modes.indexOf(playMode.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
playMode.value = modes[nextIndex]
|
||||
|
||||
// 更新用户信息
|
||||
userInfo.value.playMode = playMode.value
|
||||
}
|
||||
|
||||
// 获取播放模式图标类名
|
||||
let playModeTip = ''
|
||||
const playModeIconClass = computed(() => {
|
||||
@@ -497,179 +219,27 @@ const closePlaylist = () => {
|
||||
}
|
||||
|
||||
// 播放上一首
|
||||
const playPrevious = async () => {
|
||||
if (list.value.length === 0) return
|
||||
|
||||
try {
|
||||
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
|
||||
let prevIndex
|
||||
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
// 随机模式
|
||||
prevIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
// 顺序模式或单曲循环模式
|
||||
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
|
||||
}
|
||||
|
||||
// 确保索引有效
|
||||
if (prevIndex >= 0 && prevIndex < list.value.length) {
|
||||
await playSong(list.value[prevIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放上一首失败:', error)
|
||||
MessagePlugin.error('播放上一首失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 播放下一首
|
||||
const playNext = async () => {
|
||||
if (list.value.length === 0) return
|
||||
|
||||
try {
|
||||
// 单曲循环模式下,重新播放当前歌曲
|
||||
if (playMode.value === PlayMode.SINGLE && currentSongId.value) {
|
||||
const currentSong = list.value.find((song) => song.songmid === currentSongId.value)
|
||||
if (currentSong) {
|
||||
// 重新设置播放位置到开头
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = 0
|
||||
}
|
||||
// 如果当前正在播放,继续播放;如果暂停,保持暂停
|
||||
const startResult = start()
|
||||
if (startResult && typeof startResult.then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
|
||||
let nextIndex
|
||||
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
// 随机模式
|
||||
nextIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
// 顺序模式
|
||||
nextIndex = (currentIndex + 1) % list.value.length
|
||||
}
|
||||
|
||||
// 确保索引有效
|
||||
if (nextIndex >= 0 && nextIndex < list.value.length) {
|
||||
await playSong(list.value[nextIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放下一首失败:', error)
|
||||
MessagePlugin.error('播放下一首失败')
|
||||
}
|
||||
}
|
||||
// 上一首/下一首由全局播放管理器提供
|
||||
|
||||
// 定期保存当前播放位置
|
||||
let savePositionInterval: number | null = null
|
||||
let unEnded: () => any = () => {}
|
||||
// 全局快捷控制事件由全局播放管理器处理
|
||||
// 初始化播放器
|
||||
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()
|
||||
})
|
||||
// 监听来自主进程的锁定状态广播
|
||||
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
|
||||
desktopLyricLocked.value = !!lock
|
||||
})
|
||||
// 监听主进程通知关闭桌面歌词
|
||||
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
|
||||
desktopLyricOpen.value = false
|
||||
desktopLyricLocked.value = false
|
||||
})
|
||||
|
||||
// 检查是否有上次播放的歌曲
|
||||
// 检查是否有上次播放的歌曲
|
||||
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
|
||||
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
|
||||
if (lastPlayedSong) {
|
||||
songInfo.value = {
|
||||
...lastPlayedSong
|
||||
}
|
||||
|
||||
// 立即更新媒体会话元数据,让系统显示当前歌曲信息
|
||||
mediaSessionController.updateMetadata({
|
||||
title: lastPlayedSong.name,
|
||||
artist: lastPlayedSong.singer,
|
||||
album: lastPlayedSong.albumName || '未知专辑',
|
||||
artworkUrl: lastPlayedSong.img || defaultCoverImg
|
||||
})
|
||||
|
||||
// 如果有历史播放位置,设置为待恢复状态
|
||||
if (!Audio.value.isPlay) {
|
||||
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = lastPlayedSong.songmid
|
||||
console.log(`初始化时设置待恢复位置: ${pendingRestorePosition}秒`)
|
||||
|
||||
// 设置当前播放时间以显示进度条位置,但不清除历史记录
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = userInfo.value.currentTime
|
||||
}
|
||||
}
|
||||
// 通过工具函数获取歌曲URL
|
||||
try {
|
||||
const url = await getSongRealUrl(toRaw(lastPlayedSong))
|
||||
setUrl(url)
|
||||
} catch (error) {
|
||||
console.error('获取上次播放歌曲URL失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 同步实际播放状态,避免误写为 playing
|
||||
if (Audio.value.audio) {
|
||||
mediaSessionController.updatePlaybackState(
|
||||
Audio.value.audio.paused ? 'paused' : 'playing'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定期保存当前播放位置
|
||||
savePositionInterval = window.setInterval(() => {
|
||||
if (Audio.value.isPlay) {
|
||||
userInfo.value.currentTime = Audio.value.currentTime
|
||||
}
|
||||
}, 1000) // 每1秒保存一次
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
destroyPlaylistEventListeners()
|
||||
document.removeEventListener('keydown', KeyEvent)
|
||||
if (savePositionInterval !== null) {
|
||||
clearInterval(savePositionInterval)
|
||||
}
|
||||
if (removeMusicCtrlListener) {
|
||||
removeMusicCtrlListener()
|
||||
}
|
||||
// 清理媒体会话控制器
|
||||
mediaSessionController.cleanup()
|
||||
unEnded()
|
||||
window.electron?.ipcRenderer?.removeAllListeners?.('toogleDesktopLyricLock')
|
||||
window.electron?.ipcRenderer?.removeAllListeners?.('closeDesktopLyric')
|
||||
})
|
||||
|
||||
// 组件被激活时(从缓存中恢复)
|
||||
@@ -678,39 +248,14 @@ onActivated(async () => {
|
||||
if (isFull) {
|
||||
showFullPlay.value = true
|
||||
}
|
||||
// 如果之前正在播放,恢复播放
|
||||
// if (wasPlaying && Audio.value.url) {
|
||||
// // 恢复播放位置
|
||||
// if (Audio.value.audio && playbackPosition > 0) {
|
||||
// setCurrentTime(playbackPosition)
|
||||
// Audio.value.audio.currentTime = playbackPosition
|
||||
// }
|
||||
|
||||
// // 恢复播放
|
||||
// try {
|
||||
// const startResult = start()
|
||||
// if (startResult && typeof startResult.then === 'function') {
|
||||
// await startResult
|
||||
// }
|
||||
// console.log('恢复播放成功')
|
||||
// } catch (error) {
|
||||
// console.error('恢复播放失败:', error)
|
||||
// }
|
||||
// }
|
||||
})
|
||||
|
||||
// 组件被停用时(缓存但不销毁)
|
||||
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('暂时暂停播放,状态已保存')
|
||||
}
|
||||
})
|
||||
|
||||
// 监听用户信息变化,更新音量
|
||||
@@ -833,70 +378,6 @@ const formatTime = (seconds: number) => {
|
||||
const currentTimeFormatted = computed(() => formatTime(Audio.value.currentTime))
|
||||
const durationFormatted = computed(() => formatTime(Audio.value.duration))
|
||||
|
||||
// 专门的播放函数
|
||||
const handlePlay = async () => {
|
||||
if (!Audio.value.url) {
|
||||
// 如果没有URL但有播放列表,尝试播放第一首歌
|
||||
if (list.value.length > 0) {
|
||||
await playSong(list.value[0])
|
||||
} else {
|
||||
MessagePlugin.warning('播放列表为空,请先添加歌曲')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否需要恢复历史播放位置
|
||||
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
|
||||
console.log(`恢复播放位置: ${pendingRestorePosition}秒`)
|
||||
|
||||
// 等待音频准备就绪
|
||||
await waitForAudioReady()
|
||||
|
||||
// 设置播放位置
|
||||
setCurrentTime(pendingRestorePosition)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = pendingRestorePosition
|
||||
}
|
||||
|
||||
// 清除待恢复的位置
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
|
||||
const startResult = start()
|
||||
if (startResult && typeof startResult.then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
// 播放已开始后,同步 SMTC 状态
|
||||
mediaSessionController.updatePlaybackState('playing')
|
||||
} catch (error) {
|
||||
console.error('播放失败:', error)
|
||||
MessagePlugin.error('播放失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 专门的暂停函数
|
||||
const handlePause = async () => {
|
||||
if (Audio.value.url && Audio.value.isPlay) {
|
||||
const stopResult = stop()
|
||||
if (stopResult && typeof stopResult.then === 'function') {
|
||||
await stopResult
|
||||
}
|
||||
// 暂停后,同步 SMTC 状态
|
||||
mediaSessionController.updatePlaybackState('paused')
|
||||
}
|
||||
}
|
||||
|
||||
// 播放/暂停切换
|
||||
const togglePlayPause = async () => {
|
||||
if (Audio.value.isPlay) {
|
||||
await handlePause()
|
||||
} else {
|
||||
await handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条拖动处理
|
||||
const handleProgressClick = (event: MouseEvent) => {
|
||||
if (!progressRef.value) return
|
||||
@@ -909,11 +390,7 @@ const handleProgressClick = (event: MouseEvent) => {
|
||||
tempProgressPercentage.value = percentage
|
||||
|
||||
const newTime = (percentage / 100) * Audio.value.duration
|
||||
|
||||
setCurrentTime(newTime)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = newTime
|
||||
}
|
||||
seekTo(newTime)
|
||||
}
|
||||
|
||||
const handleProgressDragMove = (event: MouseEvent) => {
|
||||
@@ -940,11 +417,7 @@ const handleProgressDragEnd = (event: MouseEvent) => {
|
||||
const offsetX = Math.max(0, Math.min(event.clientX - rect.left, rect.width))
|
||||
const percentage = (offsetX / rect.width) * 100
|
||||
const newTime = (percentage / 100) * Audio.value.duration
|
||||
|
||||
setCurrentTime(newTime)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = newTime
|
||||
}
|
||||
seekTo(newTime)
|
||||
|
||||
isDraggingProgress.value = false
|
||||
window.removeEventListener('mousemove', handleProgressDragMove)
|
||||
@@ -960,22 +433,7 @@ const handleProgressDragStart = (event: MouseEvent) => {
|
||||
window.addEventListener('mouseup', handleProgressDragEnd)
|
||||
}
|
||||
|
||||
// 歌曲信息
|
||||
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
|
||||
songmid: null,
|
||||
hash: '',
|
||||
name: '欢迎使用CeruMusic 🎉',
|
||||
singer: '可以配置音源插件来播放你的歌曲',
|
||||
albumName: '',
|
||||
albumId: '0',
|
||||
source: '',
|
||||
interval: '00:00',
|
||||
img: '',
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {}
|
||||
})
|
||||
// 歌曲信息由全局播放管理器提供
|
||||
const maincolor = ref('var(--td-brand-color-5)')
|
||||
const startmaincolor = ref('rgba(0, 0, 0, 1)')
|
||||
const contrastTextColor = ref('rgba(0, 0, 0, .8)')
|
||||
@@ -1304,38 +762,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 +804,6 @@ watch(showFullPlay, (val) => {
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
// transition: opacity 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
@@ -1356,6 +812,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;
|
||||
}
|
||||
|
||||
@@ -34,15 +34,20 @@ const menuList: MenuItem[] = [
|
||||
path: '/home/find'
|
||||
},
|
||||
{
|
||||
name: '本地',
|
||||
icon: 'icon-music',
|
||||
path: '/home/local'
|
||||
name: '歌单',
|
||||
icon: 'icon-yanchu',
|
||||
path: '/home/songlist'
|
||||
},
|
||||
{
|
||||
name: '最近',
|
||||
icon: 'icon-shijian',
|
||||
path: '/home/recent'
|
||||
name: '本地',
|
||||
icon: 'icon-shouye',
|
||||
path: '/home/local'
|
||||
}
|
||||
// {
|
||||
// name: '最近',
|
||||
// icon: 'icon-shijian',
|
||||
// path: '/home/recent'
|
||||
// }
|
||||
]
|
||||
const menuActive = ref(0)
|
||||
const router = useRouter()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 80,
|
||||
url: ''
|
||||
url: '',
|
||||
eventInit: false
|
||||
})
|
||||
|
||||
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ControlAudioState = {
|
||||
duration: number
|
||||
volume: number
|
||||
url: string
|
||||
eventInit: boolean
|
||||
}
|
||||
|
||||
export type SongList = playList
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 全局音频管理器,用于管理音频源和分析器
|
||||
|
||||
class AudioManager {
|
||||
private static instance: AudioManager
|
||||
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
|
||||
@@ -28,8 +29,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)
|
||||
|
||||
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal file
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { ref, toRaw } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { PlayMode, type SongList } from '@renderer/types/audio'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import defaultCoverImg from '/default-cover.png'
|
||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||
import {
|
||||
getSongRealUrl,
|
||||
initPlaylistEventListeners,
|
||||
destroyPlaylistEventListeners
|
||||
} from '@renderer/utils/playlist/playlistManager'
|
||||
|
||||
const controlAudio = ControlAudioStore()
|
||||
const localUserStore = LocalUserDetailStore()
|
||||
const { Audio } = storeToRefs(controlAudio)
|
||||
const { list, userInfo } = storeToRefs(localUserStore)
|
||||
|
||||
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
|
||||
songmid: null,
|
||||
hash: '',
|
||||
name: '欢迎使用CeruMusic 🎉',
|
||||
singer: '可以配置音源插件来播放你的歌曲',
|
||||
albumName: '',
|
||||
albumId: '0',
|
||||
source: '',
|
||||
interval: '00:00',
|
||||
img: '',
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {}
|
||||
})
|
||||
|
||||
let pendingRestorePosition = 0
|
||||
let pendingRestoreSongId: number | string | null = null
|
||||
|
||||
const waitForAudioReady = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = Audio.value.audio
|
||||
if (!audio) {
|
||||
reject(new Error('音频元素未初始化'))
|
||||
return
|
||||
}
|
||||
if (audio.readyState >= 3) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载超时'))
|
||||
}, 10000)
|
||||
const onCanPlay = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
const onError = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载失败'))
|
||||
}
|
||||
audio.addEventListener('canplay', onCanPlay, { once: true })
|
||||
audio.addEventListener('error', onError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const setUrl = controlAudio.setUrl
|
||||
const start = controlAudio.start
|
||||
const stop = controlAudio.stop
|
||||
const setCurrentTime = controlAudio.setCurrentTime
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!Audio.value.url) {
|
||||
if (list.value.length > 0) {
|
||||
await playSong(list.value[0])
|
||||
} else {
|
||||
MessagePlugin.warning('播放列表为空,请先添加歌曲')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
|
||||
await waitForAudioReady()
|
||||
setCurrentTime(pendingRestorePosition)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = pendingRestorePosition
|
||||
}
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
const startResult = start()
|
||||
if (startResult && typeof (startResult as any).then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
mediaSessionController.updatePlaybackState('playing')
|
||||
} catch (error) {
|
||||
MessagePlugin.error('播放失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async () => {
|
||||
const a = Audio.value.audio
|
||||
if (Audio.value.url && a && !a.paused) {
|
||||
const stopResult = stop()
|
||||
if (stopResult && typeof (stopResult as any).then === 'function') {
|
||||
await stopResult
|
||||
}
|
||||
mediaSessionController.updatePlaybackState('paused')
|
||||
} else if (Audio.value.url) {
|
||||
mediaSessionController.updatePlaybackState('paused')
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
const a = Audio.value.audio
|
||||
const isActuallyPlaying = a ? !a.paused : Audio.value.isPlay
|
||||
if (isActuallyPlaying) {
|
||||
await handlePause()
|
||||
} else {
|
||||
await handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = async (song: SongList) => {
|
||||
try {
|
||||
isLoadingSong.value = true
|
||||
const isHistoryPlay =
|
||||
song.songmid === userInfo.value.lastPlaySongId &&
|
||||
userInfo.value.currentTime !== undefined &&
|
||||
userInfo.value.currentTime > 0
|
||||
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = song.songmid
|
||||
userInfo.value.currentTime = 0
|
||||
} else {
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
if (Audio.value.isPlay && Audio.value.audio) {
|
||||
Audio.value.isPlay = false
|
||||
Audio.value.audio.pause()
|
||||
Audio.value.audio.volume = Audio.value.volume / 100
|
||||
}
|
||||
songInfo.value.name = song.name
|
||||
songInfo.value.singer = song.singer
|
||||
songInfo.value.albumName = song.albumName
|
||||
songInfo.value.img = song.img
|
||||
userInfo.value.lastPlaySongId = song.songmid
|
||||
mediaSessionController.updateMetadata({
|
||||
title: song.name,
|
||||
artist: song.singer,
|
||||
album: song.albumName || '未知专辑',
|
||||
artworkUrl: song.img || defaultCoverImg
|
||||
})
|
||||
let urlToPlay = ''
|
||||
try {
|
||||
urlToPlay = await getSongRealUrl(toRaw(song))
|
||||
} catch (error: any) {
|
||||
isLoadingSong.value = false
|
||||
tryAutoNext('获取歌曲 URL 失败')
|
||||
return
|
||||
}
|
||||
if (Audio.value.audio) {
|
||||
const a = Audio.value.audio
|
||||
try {
|
||||
a.pause()
|
||||
} catch {}
|
||||
a.removeAttribute('src')
|
||||
a.load()
|
||||
}
|
||||
setUrl(urlToPlay)
|
||||
await waitForAudioReady()
|
||||
songInfo.value = { ...song }
|
||||
isLoadingSong.value = false
|
||||
start()
|
||||
.catch(async () => {
|
||||
tryAutoNext('启动播放失败')
|
||||
})
|
||||
.then(() => {
|
||||
autoNextCount.value = 0
|
||||
})
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.addEventListener(
|
||||
'playing',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
Audio.value.audio.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
tryAutoNext('播放歌曲失败')
|
||||
isLoadingSong.value = false
|
||||
} finally {
|
||||
isLoadingSong.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tryAutoNext = (reason: string) => {
|
||||
const limit = getAutoNextLimit()
|
||||
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
|
||||
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
|
||||
MessagePlugin.error(
|
||||
`自动下一首失败:超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
|
||||
)
|
||||
return
|
||||
}
|
||||
autoNextCount.value++
|
||||
playNext()
|
||||
}
|
||||
|
||||
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
|
||||
const isLoadingSong = ref(false)
|
||||
const autoNextCount = ref(0)
|
||||
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
|
||||
|
||||
const updatePlayMode = () => {
|
||||
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
|
||||
const currentIndex = modes.indexOf(playMode.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
playMode.value = modes[nextIndex]
|
||||
userInfo.value.playMode = playMode.value
|
||||
}
|
||||
|
||||
const playPrevious = async () => {
|
||||
if (list.value.length === 0) return
|
||||
try {
|
||||
const currentIndex = list.value.findIndex(
|
||||
(song) => song.songmid === userInfo.value.lastPlaySongId
|
||||
)
|
||||
let prevIndex
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
prevIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
|
||||
}
|
||||
if (prevIndex >= 0 && prevIndex < list.value.length) {
|
||||
await playSong(list.value[prevIndex])
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.error('播放上一首失败')
|
||||
}
|
||||
}
|
||||
|
||||
const playNext = async () => {
|
||||
if (list.value.length === 0) return
|
||||
try {
|
||||
if (playMode.value === PlayMode.SINGLE && userInfo.value.lastPlaySongId) {
|
||||
const currentSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
|
||||
if (currentSong) {
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = 0
|
||||
}
|
||||
const startResult = start()
|
||||
if (startResult && typeof (startResult as any).then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
const currentIndex = list.value.findIndex(
|
||||
(song) => song.songmid === userInfo.value.lastPlaySongId
|
||||
)
|
||||
let nextIndex
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
nextIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
nextIndex = (currentIndex + 1) % list.value.length
|
||||
}
|
||||
if (nextIndex >= 0 && nextIndex < list.value.length) {
|
||||
await playSong(list.value[nextIndex])
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.error('播放下一首失败')
|
||||
}
|
||||
}
|
||||
|
||||
const setVolume = (v: number) => controlAudio.setVolume(v)
|
||||
const seekTo = (time: number) => {
|
||||
setCurrentTime(time)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = time
|
||||
}
|
||||
}
|
||||
|
||||
let savePositionInterval: number | null = null
|
||||
const onGlobalCtrl = (e: any) => {
|
||||
const name = e?.detail?.name
|
||||
const val = e?.detail?.val
|
||||
switch (name) {
|
||||
case 'play':
|
||||
void handlePlay()
|
||||
break
|
||||
case 'pause':
|
||||
void handlePause()
|
||||
break
|
||||
case 'toggle':
|
||||
void togglePlayPause()
|
||||
break
|
||||
case 'playPrev':
|
||||
void playPrevious()
|
||||
break
|
||||
case 'playNext':
|
||||
void playNext()
|
||||
break
|
||||
case 'volumeDelta':
|
||||
{
|
||||
const next = Math.max(0, Math.min(100, (Audio.value.volume || 0) + (Number(val) || 0)))
|
||||
setVolume(next)
|
||||
}
|
||||
break
|
||||
case 'seekDelta':
|
||||
{
|
||||
const a = Audio.value.audio
|
||||
if (a) {
|
||||
const cur = a.currentTime || 0
|
||||
const target = Math.max(0, Math.min(a.duration || 0, cur + (Number(val) || 0)))
|
||||
seekTo(target)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const initPlayback = async () => {
|
||||
initPlaylistEventListeners(localUserStore, playSong)
|
||||
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
|
||||
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
|
||||
if (lastPlayedSong) {
|
||||
songInfo.value = { ...lastPlayedSong }
|
||||
mediaSessionController.updateMetadata({
|
||||
title: lastPlayedSong.name,
|
||||
artist: lastPlayedSong.singer,
|
||||
album: lastPlayedSong.albumName || '未知专辑',
|
||||
artworkUrl: lastPlayedSong.img || defaultCoverImg
|
||||
})
|
||||
if (!Audio.value.isPlay) {
|
||||
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = lastPlayedSong.songmid
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = userInfo.value.currentTime
|
||||
}
|
||||
}
|
||||
try {
|
||||
const url = await getSongRealUrl(toRaw(lastPlayedSong))
|
||||
setUrl(url)
|
||||
} catch {}
|
||||
} else {
|
||||
if (Audio.value.audio) {
|
||||
mediaSessionController.updatePlaybackState(
|
||||
Audio.value.audio.paused ? 'paused' : 'playing'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
savePositionInterval = window.setInterval(() => {
|
||||
if (Audio.value.isPlay) {
|
||||
userInfo.value.currentTime = Audio.value.currentTime
|
||||
}
|
||||
}, 1000)
|
||||
window.addEventListener('global-music-control', onGlobalCtrl)
|
||||
controlAudio.subscribe('ended', () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
void playNext()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const destroyPlayback = () => {
|
||||
destroyPlaylistEventListeners()
|
||||
window.removeEventListener('global-music-control', onGlobalCtrl)
|
||||
if (savePositionInterval !== null) {
|
||||
clearInterval(savePositionInterval)
|
||||
savePositionInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
songInfo,
|
||||
playMode,
|
||||
isLoadingSong,
|
||||
initPlayback,
|
||||
destroyPlayback,
|
||||
playSong,
|
||||
playNext,
|
||||
playPrevious,
|
||||
updatePlayMode,
|
||||
togglePlayPause,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
setVolume,
|
||||
seekTo
|
||||
}
|
||||
109
src/renderer/src/utils/audio/globalControls.ts
Normal file
109
src/renderer/src/utils/audio/globalControls.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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()
|
||||
dispatch('volumeDelta', 5)
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
dispatch('volumeDelta', -5)
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
dispatch('seekDelta', -5)
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
dispatch('seekDelta', 5)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
||||
// // 监听音频结束事件,根据播放模式播放下一首
|
||||
// controlAudio.subscribe('ended', () => {
|
||||
// window.requestAnimationFrame(() => {
|
||||
// console.log('播放结束')
|
||||
// dispatch('playNext')
|
||||
// })
|
||||
// })
|
||||
// 托盘或系统快捷键回调(若存在)
|
||||
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