mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-26 03:45:03 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9545f32c81 | ||
|
|
7945108243 | ||
|
|
6ce05d286f | ||
|
|
8209d021de | ||
|
|
a277cb7181 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ceru-music",
|
"name": "ceru-music",
|
||||||
"version": "1.4.7",
|
"version": "1.5.0",
|
||||||
"description": "一款简洁优雅的音乐播放器",
|
"description": "一款简洁优雅的音乐播放器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "sqj,wldss,star",
|
"author": "sqj,wldss,star",
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
"@pixi/sprite": "^7.4.3",
|
"@pixi/sprite": "^7.4.3",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/needle": "^3.3.0",
|
"@types/needle": "^3.3.0",
|
||||||
"NeteaseCloudMusicApi": "^4.27.0",
|
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"color-extraction": "^1.0.8",
|
"color-extraction": "^1.0.8",
|
||||||
|
|||||||
692
playlist-converter.html
Normal file
692
playlist-converter.html
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>歌单格式转换器</title>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
background: #f8f9ff;
|
||||||
|
border: 3px dashed #4facfe;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section:hover {
|
||||||
|
border-color: #00f2fe;
|
||||||
|
background: #f0f8ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section.dragover {
|
||||||
|
border-color: #00f2fe;
|
||||||
|
background: #e8f4ff;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 4em;
|
||||||
|
color: #4facfe;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 1.3em;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtext {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-count {
|
||||||
|
background: #4facfe;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-number {
|
||||||
|
background: #4facfe;
|
||||||
|
color: white;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-artist {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f1f3f4;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
display: none;
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
font-size: 4em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-success {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-message {
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 25px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-link:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #4facfe;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-details {
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #c53030;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎵 澜音歌单格式转换器</h1>
|
||||||
|
<p>将洛雪音乐平台的歌单转换为澜音音乐的加密格式</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="upload-section" id="uploadSection">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<div class="upload-text">点击或拖拽洛雪歌单文件到此处</div>
|
||||||
|
<div class="upload-subtext">支持 *.json 和 *.lxmc 格式的歌单文件</div>
|
||||||
|
<input type="file" id="fileInput" class="file-input" accept=".json,.lxmc" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-section" id="previewSection">
|
||||||
|
<div class="preview-header">
|
||||||
|
<div class="preview-title">歌单预览</div>
|
||||||
|
<div class="song-count" id="songCount">0 首歌曲</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-content" id="previewContent">
|
||||||
|
<!-- 歌曲列表将在这里显示 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" id="controls" style="display: none">
|
||||||
|
<button class="btn btn-primary" id="convertBtn">
|
||||||
|
<span>🔐</span>
|
||||||
|
转换并加密
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" id="resetBtn">
|
||||||
|
<span>🔄</span>
|
||||||
|
重新选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loadingSection">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div>正在转换中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-section" id="resultSection">
|
||||||
|
<div class="result-icon" id="resultIcon"></div>
|
||||||
|
<div class="result-message" id="resultMessage"></div>
|
||||||
|
<a href="#" class="download-link" id="downloadLink" style="display: none">
|
||||||
|
<span>📥</span>
|
||||||
|
下载澜音歌单
|
||||||
|
</a>
|
||||||
|
<div class="error-details" id="errorDetails" style="display: none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 全局变量
|
||||||
|
let currentPlaylist = null
|
||||||
|
let convertedPlaylist = null
|
||||||
|
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
|
||||||
|
|
||||||
|
// Gzip解压函数
|
||||||
|
async function decompressGzip(arrayBuffer) {
|
||||||
|
try {
|
||||||
|
// 使用浏览器的 DecompressionStream API
|
||||||
|
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'))
|
||||||
|
const decompressed = await new Response(stream).arrayBuffer()
|
||||||
|
return new TextDecoder().decode(decompressed)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Gzip解压失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM元素
|
||||||
|
const uploadSection = document.getElementById('uploadSection')
|
||||||
|
const fileInput = document.getElementById('fileInput')
|
||||||
|
const previewSection = document.getElementById('previewSection')
|
||||||
|
const previewContent = document.getElementById('previewContent')
|
||||||
|
const songCount = document.getElementById('songCount')
|
||||||
|
const controls = document.getElementById('controls')
|
||||||
|
const convertBtn = document.getElementById('convertBtn')
|
||||||
|
const resetBtn = document.getElementById('resetBtn')
|
||||||
|
const loadingSection = document.getElementById('loadingSection')
|
||||||
|
const resultSection = document.getElementById('resultSection')
|
||||||
|
const resultIcon = document.getElementById('resultIcon')
|
||||||
|
const resultMessage = document.getElementById('resultMessage')
|
||||||
|
const downloadLink = document.getElementById('downloadLink')
|
||||||
|
const errorDetails = document.getElementById('errorDetails')
|
||||||
|
|
||||||
|
// 文件上传处理
|
||||||
|
uploadSection.addEventListener('click', () => fileInput.click())
|
||||||
|
|
||||||
|
uploadSection.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
uploadSection.classList.add('dragover')
|
||||||
|
})
|
||||||
|
|
||||||
|
uploadSection.addEventListener('dragleave', () => {
|
||||||
|
uploadSection.classList.remove('dragover')
|
||||||
|
})
|
||||||
|
|
||||||
|
uploadSection.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
uploadSection.classList.remove('dragover')
|
||||||
|
const files = e.dataTransfer.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFile(files[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
handleFile(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按钮事件
|
||||||
|
convertBtn.addEventListener('click', convertPlaylist)
|
||||||
|
resetBtn.addEventListener('click', resetConverter)
|
||||||
|
|
||||||
|
// 处理文件
|
||||||
|
function handleFile(file) {
|
||||||
|
const fileName = file.name.toLowerCase()
|
||||||
|
|
||||||
|
if (fileName.endsWith('.json')) {
|
||||||
|
// 处理JSON文件
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(e.target.result)
|
||||||
|
currentPlaylist = content
|
||||||
|
displayPreview(content)
|
||||||
|
} catch (error) {
|
||||||
|
showError('JSON文件解析失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
} else if (fileName.endsWith('.lxmc')) {
|
||||||
|
// 处理LXMC文件(gzip压缩)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = e.target.result
|
||||||
|
const decompressed = await decompressGzip(arrayBuffer)
|
||||||
|
const content = JSON.parse(decompressed)
|
||||||
|
currentPlaylist = content
|
||||||
|
displayPreview(content)
|
||||||
|
} catch (error) {
|
||||||
|
showError('LXMC文件处理失败: ' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
} else {
|
||||||
|
showError('请选择JSON或LXMC格式的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示预览
|
||||||
|
function displayPreview(playlist) {
|
||||||
|
previewContent.innerHTML = ''
|
||||||
|
|
||||||
|
const playlistData =
|
||||||
|
playlist.type === 'playListPart_v2' && playlist.data ? playlist.data : playlist
|
||||||
|
const songList = playlistData.list
|
||||||
|
|
||||||
|
if (!songList || !Array.isArray(songList)) {
|
||||||
|
showError('无效的歌单格式:缺少歌曲列表')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示歌曲列表
|
||||||
|
songList.forEach((song, index) => {
|
||||||
|
const songItem = document.createElement('div')
|
||||||
|
songItem.className = 'song-item'
|
||||||
|
songItem.innerHTML = `
|
||||||
|
<div class="song-number">${index + 1}</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-name">${song.name || '未知歌曲'}</div>
|
||||||
|
<div class="song-artist">${song.singer || '未知艺术家'} • ${song.interval || '0:00'}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
previewContent.appendChild(songItem)
|
||||||
|
})
|
||||||
|
|
||||||
|
songCount.textContent = `${songList.length} 首歌曲`
|
||||||
|
previewSection.style.display = 'block'
|
||||||
|
controls.style.display = 'flex'
|
||||||
|
uploadSection.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换歌单
|
||||||
|
function convertPlaylist() {
|
||||||
|
if (!currentPlaylist) return
|
||||||
|
|
||||||
|
showLoading()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 转换格式
|
||||||
|
const convertedSongs = convertToTargetFormat(currentPlaylist)
|
||||||
|
|
||||||
|
// 加密
|
||||||
|
const encryptedData = encryptPlaylist(convertedSongs)
|
||||||
|
|
||||||
|
convertedPlaylist = {
|
||||||
|
original: currentPlaylist,
|
||||||
|
converted: convertedSongs,
|
||||||
|
encrypted: encryptedData
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(convertedPlaylist)
|
||||||
|
} catch (error) {
|
||||||
|
showError('转换失败: ' + error.message, error.stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为目标格式
|
||||||
|
function convertToTargetFormat(originalPlaylist) {
|
||||||
|
if (originalPlaylist.type === 'playListPart_v2' && originalPlaylist.data) {
|
||||||
|
originalPlaylist = originalPlaylist.data
|
||||||
|
}
|
||||||
|
if (!originalPlaylist.list || !Array.isArray(originalPlaylist.list)) {
|
||||||
|
throw new Error('原始歌单格式无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPlaylist.list.map((song) => {
|
||||||
|
// 从meta中提取额外信息
|
||||||
|
const meta = song.meta || {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
songmid: meta.hash || meta.songId,
|
||||||
|
singer: song.singer || '未知艺术家',
|
||||||
|
name: song.name || '未知歌曲',
|
||||||
|
albumName: meta.albumName || '未知专辑',
|
||||||
|
albumId: meta.albumId,
|
||||||
|
source: song.source || 'unknown',
|
||||||
|
interval: song.interval || '0:00',
|
||||||
|
img: meta.picUrl || '',
|
||||||
|
lrc: null,
|
||||||
|
types: meta.qualitys,
|
||||||
|
_types: meta._qualitys,
|
||||||
|
typeUrl: {},
|
||||||
|
url: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换音质类型
|
||||||
|
function convertQualityTypes(qualities) {
|
||||||
|
const types = {}
|
||||||
|
if (!qualities || !Array.isArray(qualities)) {
|
||||||
|
types['128k'] = { size: '未知' }
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
qualities.forEach((quality) => {
|
||||||
|
if (typeof quality.type !== 'string') return
|
||||||
|
const q = quality.type.toLowerCase()
|
||||||
|
if (q.includes('flac')) {
|
||||||
|
if (q.includes('24')) {
|
||||||
|
types['flac24bit'] = { size: '未知' }
|
||||||
|
} else {
|
||||||
|
types['flac'] = { size: '未知' }
|
||||||
|
}
|
||||||
|
} else if (q.includes('320')) {
|
||||||
|
types['320k'] = { size: '未知' }
|
||||||
|
} else if (q.includes('128')) {
|
||||||
|
types['128k'] = { size: '未知' }
|
||||||
|
} else {
|
||||||
|
types[q] = { size: '未知' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.keys(types).length > 0 ? types : { '128k': { size: '未知' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机ID
|
||||||
|
function generateRandomId() {
|
||||||
|
return Math.random().toString(36).substr(2, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密歌单
|
||||||
|
function encryptPlaylist(songs) {
|
||||||
|
const dataToEncrypt = JSON.stringify(songs)
|
||||||
|
return CryptoJS.AES.encrypt(dataToEncrypt, SECRET_KEY).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
function showLoading() {
|
||||||
|
loadingSection.style.display = 'block'
|
||||||
|
controls.style.display = 'none'
|
||||||
|
resultSection.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示成功结果
|
||||||
|
function showSuccess(result) {
|
||||||
|
loadingSection.style.display = 'none'
|
||||||
|
resultSection.style.display = 'block'
|
||||||
|
|
||||||
|
resultIcon.className = 'result-icon result-success'
|
||||||
|
resultIcon.textContent = '✅'
|
||||||
|
resultMessage.textContent = '歌单转换成功!'
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const encryptedData = result.encrypted
|
||||||
|
const blob = new Blob([encryptedData], {
|
||||||
|
type: 'application/octet-stream'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
downloadLink.href = url
|
||||||
|
downloadLink.download = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
|
||||||
|
downloadLink.style.display = 'inline-block'
|
||||||
|
|
||||||
|
// 清理之前的URL
|
||||||
|
if (downloadLink.dataset.url) {
|
||||||
|
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||||
|
}
|
||||||
|
downloadLink.dataset.url = url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误信息
|
||||||
|
function showError(message, details = null) {
|
||||||
|
loadingSection.style.display = 'none'
|
||||||
|
resultSection.style.display = 'block'
|
||||||
|
|
||||||
|
resultIcon.className = 'result-icon result-error'
|
||||||
|
resultIcon.textContent = '❌'
|
||||||
|
resultMessage.textContent = message
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
errorDetails.textContent = details
|
||||||
|
errorDetails.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLink.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置转换器
|
||||||
|
function resetConverter() {
|
||||||
|
currentPlaylist = null
|
||||||
|
convertedPlaylist = null
|
||||||
|
|
||||||
|
uploadSection.style.display = 'block'
|
||||||
|
previewSection.style.display = 'none'
|
||||||
|
controls.style.display = 'none'
|
||||||
|
loadingSection.style.display = 'none'
|
||||||
|
resultSection.style.display = 'none'
|
||||||
|
|
||||||
|
fileInput.value = ''
|
||||||
|
previewContent.innerHTML = ''
|
||||||
|
errorDetails.style.display = 'none'
|
||||||
|
|
||||||
|
// 清理下载链接
|
||||||
|
if (downloadLink.dataset.url) {
|
||||||
|
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||||
|
delete downloadLink.dataset.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面卸载时清理资源
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (downloadLink.dataset.url) {
|
||||||
|
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
src/common/types/localSongs.ts
Normal file
9
src/common/types/localSongs.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export default interface localList {
|
||||||
|
singer: string
|
||||||
|
name: string
|
||||||
|
albumName: string
|
||||||
|
interval: string
|
||||||
|
duration: string
|
||||||
|
img: string
|
||||||
|
lrc: null | string
|
||||||
|
}
|
||||||
@@ -11,5 +11,6 @@ export default interface PlayList {
|
|||||||
lrc: null | string
|
lrc: null | string
|
||||||
types: string[]
|
types: string[]
|
||||||
_types: Record<string, any>
|
_types: Record<string, any>
|
||||||
typeUrl: Record<string, any>
|
typeUrl?: Record<string, any>
|
||||||
|
url?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import InitPluginService from './plugins'
|
import InitPluginService from './plugins'
|
||||||
import '../services/musicSdk/index'
|
import '../services/musicSdk/index'
|
||||||
import aiEvents from '../events/ai'
|
import aiEvents from '../events/ai'
|
||||||
|
// import initLocalMusicEvents from './localMusic'
|
||||||
import { app, powerSaveBlocker } from 'electron'
|
import { app, powerSaveBlocker } from 'electron'
|
||||||
import { type BrowserWindow, ipcMain } from 'electron'
|
import { type BrowserWindow, ipcMain } from 'electron'
|
||||||
export default function InitEventServices(mainWindow: BrowserWindow) {
|
export default function InitEventServices(mainWindow: BrowserWindow) {
|
||||||
InitPluginService()
|
InitPluginService()
|
||||||
aiEvents(mainWindow)
|
aiEvents(mainWindow)
|
||||||
|
// initLocalMusicEvents()
|
||||||
basisEvent(mainWindow)
|
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) => {
|
ipcMain.on('change-desktop-lyric', (_event, val: boolean) => {
|
||||||
|
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
|
||||||
|
|
||||||
if (val) {
|
if (val) {
|
||||||
lyricWin?.show()
|
lyricWin?.show()
|
||||||
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
|
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
|
||||||
@@ -39,17 +41,37 @@ const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
|
|||||||
// 音乐名称更改
|
// 音乐名称更改
|
||||||
ipcMain.on('play-song-change', (_, title) => {
|
ipcMain.on('play-song-change', (_, title) => {
|
||||||
if (!title) return
|
if (!title) return
|
||||||
|
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
|
||||||
|
|
||||||
lyricWin?.webContents.send('play-song-change', title)
|
lyricWin?.webContents.send('play-song-change', title)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 音乐歌词更改
|
// 音乐歌词更改
|
||||||
ipcMain.on('play-lyric-change', (_, lyricData) => {
|
ipcMain.on('play-lyric-change', (_, lyricData) => {
|
||||||
if (!lyricData) return
|
if (!lyricData) return
|
||||||
|
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
|
||||||
|
|
||||||
lyricWin?.webContents.send('play-lyric-change', lyricData)
|
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) => {
|
ipcMain.on('play-status-change', (_, status: boolean) => {
|
||||||
|
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
|
||||||
lyricWin?.webContents.send('play-status-change', status)
|
lyricWin?.webContents.send('play-status-change', status)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
|
|
||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
export function initPluginNotice(mainWindowInstance: BrowserWindow): void {
|
||||||
|
mainWindow = mainWindowInstance
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginNoticeData {
|
export interface PluginNoticeData {
|
||||||
type: 'error' | 'info' | 'success' | 'warn' | 'update'
|
type: 'error' | 'info' | 'success' | 'warn' | 'update'
|
||||||
data: {
|
data: {
|
||||||
@@ -100,7 +106,6 @@ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: stri
|
|||||||
// console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
|
// console.log(`[CeruMusic] 插件通知: ${noticeData.type}`, noticeData.data)
|
||||||
|
|
||||||
// 获取主窗口实例
|
// 获取主窗口实例
|
||||||
const mainWindow = BrowserWindow.getAllWindows().find((win) => !win.isDestroyed())
|
|
||||||
if (!mainWindow) {
|
if (!mainWindow) {
|
||||||
console.warn('[CeruMusic] 未找到主窗口,无法发送通知')
|
console.warn('[CeruMusic] 未找到主窗口,无法发送通知')
|
||||||
return
|
return
|
||||||
@@ -152,7 +157,6 @@ export function sendPluginNotice(noticeData: PluginNoticeData, pluginName?: stri
|
|||||||
getDefaultMessage(noticeData.type, noticeData.data, baseNoticeData.pluginName),
|
getDefaultMessage(noticeData.type, noticeData.data, baseNoticeData.pluginName),
|
||||||
actions: [{ text: '我知道了', type: 'confirm', primary: true }]
|
actions: [{ text: '我知道了', type: 'confirm', primary: true }]
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.webContents.send('plugin-notice', infoNotice)
|
mainWindow.webContents.send('plugin-notice', infoNotice)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import './events/songList'
|
|||||||
import './events/directorySettings'
|
import './events/directorySettings'
|
||||||
import './events/pluginNotice'
|
import './events/pluginNotice'
|
||||||
import initLyricIpc from './events/lyric'
|
import initLyricIpc from './events/lyric'
|
||||||
|
import { initPluginNotice } from './events/pluginNotice'
|
||||||
|
import './events/localMusic'
|
||||||
|
|
||||||
// 获取单实例锁
|
// 获取单实例锁
|
||||||
const gotTheLock = app.requestSingleInstanceLock()
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
@@ -262,7 +264,7 @@ function createWindow(): void {
|
|||||||
return { action: 'deny' }
|
return { action: 'deny' }
|
||||||
})
|
})
|
||||||
InitEventServices(mainWindow)
|
InitEventServices(mainWindow)
|
||||||
|
initPluginNotice(mainWindow)
|
||||||
// HMR for renderer base on electron-vite cli.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
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) {
|
if (!Array.isArray(songs) || songs.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证和过滤有效歌曲
|
// 验证和过滤有效歌曲
|
||||||
const validSongs = songs.filter(PlayListUtils.isValidSong)
|
const validSongs = songs.filter(PlayListUtils.isValidSong)
|
||||||
if (validSongs.length === 0) {
|
if (validSongs.length === 0) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
|
// : `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`
|
// 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) {
|
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://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}`
|
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
|
||||||
|
|||||||
@@ -19,25 +19,49 @@ export default {
|
|||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
comm: {
|
comm: {
|
||||||
ct: 11,
|
ct: '11',
|
||||||
cv: '1003006',
|
cv: '14090508',
|
||||||
v: '1003006',
|
v: '14090508',
|
||||||
|
tmeAppID: 'qqmusic',
|
||||||
|
phonetype: 'EBG-AN10',
|
||||||
|
deviceScore: '553.47',
|
||||||
|
devicelevel: '50',
|
||||||
|
newdevicelevel: '20',
|
||||||
|
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
|
||||||
os_ver: '12',
|
os_ver: '12',
|
||||||
phonetype: '0',
|
OpenUDID: '0',
|
||||||
devicelevel: '31',
|
OpenUDID2: '0',
|
||||||
tmeAppID: 'qqmusiclight',
|
QIMEI36: '0',
|
||||||
nettype: 'NETWORK_WIFI',
|
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: {
|
req: {
|
||||||
module: 'music.search.SearchCgiService',
|
module: 'music.search.SearchCgiService',
|
||||||
method: 'DoSearchForQQMusicLite',
|
method: 'DoSearchForQQMusicMobile',
|
||||||
param: {
|
param: {
|
||||||
query: str,
|
|
||||||
search_type: 0,
|
search_type: 0,
|
||||||
num_per_page: limit,
|
query: str,
|
||||||
page_num: page,
|
page_num: page,
|
||||||
|
num_per_page: limit,
|
||||||
|
highlight: 0,
|
||||||
nqc_flag: 0,
|
nqc_flag: 0,
|
||||||
|
multi_zhida: 0,
|
||||||
|
cat: 2,
|
||||||
grp: 1,
|
grp: 1,
|
||||||
|
sin: 0,
|
||||||
|
sem: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class LyricWindow {
|
|||||||
const bounds = this.win?.getBounds()
|
const bounds = this.win?.getBounds()
|
||||||
if (bounds) {
|
if (bounds) {
|
||||||
const { width, height } = bounds
|
const { width, height } = bounds
|
||||||
console.log('歌词窗口缩放:', width, height);
|
console.log('歌词窗口缩放:', width, height)
|
||||||
|
|
||||||
lyricStore.set({
|
lyricStore.set({
|
||||||
...lyricStore.get(),
|
...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: {
|
pluginNotice: {
|
||||||
onPluginNotice: (listener: (...args: any[]) => void) => () => void
|
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 {
|
declare global {
|
||||||
|
|||||||
@@ -190,6 +190,29 @@ const api = {
|
|||||||
ipcRenderer.invoke('directory-settings:get-directory-size', dirPath)
|
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: {
|
pluginNotice: {
|
||||||
onPluginNotice(callback: (data: string) => any) {
|
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']
|
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
|
||||||
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
|
||||||
NBadge: typeof import('naive-ui')['NBadge']
|
NBadge: typeof import('naive-ui')['NBadge']
|
||||||
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
|
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||||
NCard: typeof import('naive-ui')['NCard']
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
|
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||||
|
NDropdown: typeof import('naive-ui')['NDropdown']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
NSpin: typeof import('naive-ui')['NSpin']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
|
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
|
||||||
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
|
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
|
||||||
|
|||||||
@@ -31,12 +31,22 @@ onMounted(() => {
|
|||||||
syncNaiveTheme()
|
syncNaiveTheme()
|
||||||
window.addEventListener('theme-changed', () => 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) => {
|
const forward = (name: string, val?: any) => {
|
||||||
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
|
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
|
||||||
}
|
}
|
||||||
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
|
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
|
||||||
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
|
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?.('playPrev', () => forward('playPrev'))
|
||||||
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
|
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
|
||||||
|
|
||||||
|
|||||||
@@ -304,8 +304,8 @@
|
|||||||
/* TitleBarControls 组件专用变量 - 亮色主题 */
|
/* TitleBarControls 组件专用变量 - 亮色主题 */
|
||||||
--titlebar-icon-color: #111827;
|
--titlebar-icon-color: #111827;
|
||||||
--titlebar-icon-hover: #111827;
|
--titlebar-icon-hover: #111827;
|
||||||
--titlebar-btn-hover-bg: #f3f4f6;
|
--titlebar-btn-hover-bg: #00000041;
|
||||||
--titlebar-close-hover-bg: #fee2e2;
|
--titlebar-close-hover-bg: #ffa1a176;
|
||||||
--titlebar-close-hover-color: #dc2626;
|
--titlebar-close-hover-color: #dc2626;
|
||||||
|
|
||||||
/* Settings 页面专用变量 - 亮色主题 */
|
/* Settings 页面专用变量 - 亮色主题 */
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="song-virtual-list">
|
<div class="song-virtual-list">
|
||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<div class="list-header">
|
<div class="list-header-container" style="background-color: var(--song-list-header-bg)">
|
||||||
<div v-if="showIndex" class="col-index">#</div>
|
<div class="list-header" :style="{ marginRight: hasScroll ? '10px' : '0' }">
|
||||||
<div class="col-title">标题</div>
|
<div v-if="showIndex" class="col-index">#</div>
|
||||||
<div v-if="showAlbum" class="col-album">专辑</div>
|
<div class="col-title">标题</div>
|
||||||
<div class="col-like">喜欢</div>
|
<div v-if="showAlbum" class="col-album">专辑</div>
|
||||||
<div v-if="showDuration" class="col-duration">时长</div>
|
<div class="col-like">喜欢</div>
|
||||||
|
<div v-if="showDuration" class="col-duration">时长</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 虚拟滚动容器 -->
|
<!-- 虚拟滚动容器 -->
|
||||||
@@ -201,6 +203,13 @@ const contextMenuSong = ref<Song | null>(null)
|
|||||||
// 歌单列表
|
// 歌单列表
|
||||||
const playlists = ref<SongList[]>([])
|
const playlists = ref<SongList[]>([])
|
||||||
|
|
||||||
|
const hasScroll = computed(() => {
|
||||||
|
// 判断是否有滚动条
|
||||||
|
return !!(
|
||||||
|
scrollContainer.value && scrollContainer.value.scrollHeight > scrollContainer.value.clientHeight
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 计算总高度
|
// 计算总高度
|
||||||
const totalHeight = computed(() => props.songs.length * itemHeight)
|
const totalHeight = computed(() => props.songs.length * itemHeight)
|
||||||
|
|
||||||
@@ -564,8 +573,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.list-header {
|
.list-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
grid-template-columns: 50px 1fr 200px 60px 80px;
|
||||||
padding: 8px 20px;
|
padding: 8px 10px;
|
||||||
background: var(--song-list-header-bg);
|
background: var(--song-list-header-bg);
|
||||||
border-bottom: 1px solid var(--song-list-header-border);
|
border-bottom: 1px solid var(--song-list-header-border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -583,7 +592,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-title {
|
.col-title {
|
||||||
padding-left: 20px;
|
padding-left: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -630,8 +639,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.song-item {
|
.song-item {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 60px 1fr 200px 60px 80px;
|
grid-template-columns: 50px 1fr 200px 60px 80px;
|
||||||
padding: 8px 20px;
|
padding: 8px 10px;
|
||||||
border-bottom: 1px solid var(--song-list-item-border);
|
border-bottom: 1px solid var(--song-list-item-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
@@ -664,7 +673,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 60px;
|
width: 100%;
|
||||||
|
|
||||||
.track-number {
|
.track-number {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface Props {
|
|||||||
show?: boolean
|
show?: boolean
|
||||||
coverImage?: string
|
coverImage?: string
|
||||||
songId?: string | null
|
songId?: string | null
|
||||||
songInfo: SongList | { songmid: number | null | string }
|
songInfo: SongList | { songmid: number | null | string; lrc: string | null }
|
||||||
mainColor: string
|
mainColor: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,6 @@ watch(
|
|||||||
active = false
|
active = false
|
||||||
abort.abort()
|
abort.abort()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 工具函数:清洗响应式对象,避免序列化问题
|
// 工具函数:清洗响应式对象,避免序列化问题
|
||||||
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
|
||||||
|
|
||||||
@@ -173,35 +172,100 @@ watch(
|
|||||||
let parsedLyrics: LyricLine[] = []
|
let parsedLyrics: LyricLine[] = []
|
||||||
|
|
||||||
if (source === 'wy' || source === 'tx') {
|
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 {
|
try {
|
||||||
const res = await (
|
const res = await (
|
||||||
await fetch(
|
await fetch(
|
||||||
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`,
|
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`,
|
||||||
{
|
{
|
||||||
signal: abort.signal
|
signal: abort.signal // TTML 请求使用 abort signal
|
||||||
})
|
}
|
||||||
|
)
|
||||||
).text()
|
).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 (!active) return
|
||||||
|
|
||||||
if (lyricData?.crlyric) {
|
if (!res || res.length < 100) {
|
||||||
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
|
throw new Error('ttml 无歌词') // 抛出错误以触发 catch
|
||||||
} else if (lyricData?.lyric) {
|
|
||||||
parsedLyrics = parseLrc(lyricData.lyric)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// 其他来源:直接统一歌词 API
|
||||||
const lyricData = await window.api.music.requestSdk('getLyric', {
|
const lyricData = await window.api.music.requestSdk('getLyric', {
|
||||||
source,
|
source,
|
||||||
@@ -216,6 +280,13 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
|
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
|
if (!active) return
|
||||||
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
|
||||||
@@ -229,71 +300,6 @@ watch(
|
|||||||
{ immediate: true }
|
{ 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 bgRef = ref<BackgroundRenderRef | undefined>(undefined)
|
||||||
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
|
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
|
||||||
|
|
||||||
@@ -489,7 +495,7 @@ const lyricTranslateY = computed(() => {
|
|||||||
<div v-show="state.lyricLines.length > 0" class="right">
|
<div v-show="state.lyricLines.length > 0" class="right">
|
||||||
<LyricPlayer
|
<LyricPlayer
|
||||||
ref="lyricPlayerRef"
|
ref="lyricPlayerRef"
|
||||||
:lyric-lines="props.show ? state.lyricLines : []"
|
:lyric-lines="props.show ? toRaw(state.lyricLines) : []"
|
||||||
:current-time="state.currentTime"
|
:current-time="state.currentTime"
|
||||||
class="lyric-player"
|
class="lyric-player"
|
||||||
:enable-spring="playSetting.getisJumpLyric"
|
:enable-spring="playSetting.getisJumpLyric"
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
onActivated,
|
onActivated,
|
||||||
onDeactivated,
|
onDeactivated,
|
||||||
toRaw,
|
toRaw
|
||||||
provide
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
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 { PlayMode, type SongList } from '@renderer/types/audio'
|
||||||
import { MessagePlugin } from 'tdesign-vue-next'
|
import { MessagePlugin } from 'tdesign-vue-next'
|
||||||
import {
|
import {
|
||||||
initPlaylistEventListeners,
|
songInfo,
|
||||||
destroyPlaylistEventListeners,
|
playNext,
|
||||||
getSongRealUrl
|
playPrevious,
|
||||||
} from '@renderer/utils/playlist/playlistManager'
|
updatePlayMode,
|
||||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
togglePlayPause,
|
||||||
|
isLoadingSong,
|
||||||
|
setVolume,
|
||||||
|
seekTo,
|
||||||
|
playSong,
|
||||||
|
playMode
|
||||||
|
} from '@renderer/utils/audio/globaPlayList'
|
||||||
import defaultCoverImg from '/default-cover.png'
|
import defaultCoverImg from '/default-cover.png'
|
||||||
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
import { downloadSingleSong } from '@renderer/utils/audio/download'
|
||||||
import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next'
|
import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next'
|
||||||
@@ -38,7 +43,7 @@ const controlAudio = ControlAudioStore()
|
|||||||
const localUserStore = LocalUserDetailStore()
|
const localUserStore = LocalUserDetailStore()
|
||||||
const { Audio } = storeToRefs(controlAudio)
|
const { Audio } = storeToRefs(controlAudio)
|
||||||
const { list, userInfo } = storeToRefs(localUserStore)
|
const { list, userInfo } = storeToRefs(localUserStore)
|
||||||
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
|
const {} = controlAudio
|
||||||
|
|
||||||
// 当前歌曲是否已在“我的喜欢”
|
// 当前歌曲是否已在“我的喜欢”
|
||||||
const likeState = ref(false)
|
const likeState = ref(false)
|
||||||
@@ -103,298 +108,15 @@ const toggleDesktopLyric = async () => {
|
|||||||
console.error('切换桌面歌词失败:', e)
|
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 playbackPosition = 0
|
||||||
let isFull = false
|
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 = ''
|
let playModeTip = ''
|
||||||
const playModeIconClass = computed(() => {
|
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 () => {
|
onMounted(async () => {
|
||||||
console.log('加载')
|
// 监听来自主进程的锁定状态广播
|
||||||
// 初始化播放列表事件监听器
|
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
|
||||||
initPlaylistEventListeners(localUserStore, playSong)
|
desktopLyricLocked.value = !!lock
|
||||||
|
})
|
||||||
// 初始化媒体会话控制器
|
// 监听主进程通知关闭桌面歌词
|
||||||
if (Audio.value.audio) {
|
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
|
||||||
mediaSessionController.init(Audio.value.audio, {
|
desktopLyricOpen.value = false
|
||||||
play: async () => {
|
desktopLyricLocked.value = false
|
||||||
// 专门的播放函数,只处理播放逻辑
|
|
||||||
if (!Audio.value.isPlay) {
|
|
||||||
await handlePlay()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pause: async () => {
|
|
||||||
// 专门的暂停函数,只处理暂停逻辑
|
|
||||||
if (Audio.value.isPlay) {
|
|
||||||
await handlePause()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
playPrevious: () => playPrevious(),
|
|
||||||
playNext: () => playNext()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听音频结束事件,根据播放模式播放下一首
|
|
||||||
unEnded = controlAudio.subscribe('ended', () => {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
console.log('播放结束')
|
|
||||||
playNext()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查是否有上次播放的歌曲
|
|
||||||
// 检查是否有上次播放的歌曲
|
|
||||||
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
|
|
||||||
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(() => {
|
onUnmounted(() => {
|
||||||
destroyPlaylistEventListeners()
|
window.electron?.ipcRenderer?.removeAllListeners?.('toogleDesktopLyricLock')
|
||||||
document.removeEventListener('keydown', KeyEvent)
|
window.electron?.ipcRenderer?.removeAllListeners?.('closeDesktopLyric')
|
||||||
if (savePositionInterval !== null) {
|
|
||||||
clearInterval(savePositionInterval)
|
|
||||||
}
|
|
||||||
if (removeMusicCtrlListener) {
|
|
||||||
removeMusicCtrlListener()
|
|
||||||
}
|
|
||||||
// 清理媒体会话控制器
|
|
||||||
mediaSessionController.cleanup()
|
|
||||||
unEnded()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件被激活时(从缓存中恢复)
|
// 组件被激活时(从缓存中恢复)
|
||||||
@@ -678,39 +248,14 @@ onActivated(async () => {
|
|||||||
if (isFull) {
|
if (isFull) {
|
||||||
showFullPlay.value = true
|
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(() => {
|
onDeactivated(() => {
|
||||||
console.log('PlayMusic组件被停用')
|
console.log('PlayMusic组件被停用')
|
||||||
// 保存当前播放状态
|
// 仅记录状态,不主动暂停,避免页面切换导致音乐暂停
|
||||||
wasPlaying = Audio.value.isPlay
|
// wasPlaying = Audio.value.isPlay
|
||||||
// playbackPosition = Audio.value.currentTime
|
|
||||||
isFull = showFullPlay.value
|
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 currentTimeFormatted = computed(() => formatTime(Audio.value.currentTime))
|
||||||
const durationFormatted = computed(() => formatTime(Audio.value.duration))
|
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) => {
|
const handleProgressClick = (event: MouseEvent) => {
|
||||||
if (!progressRef.value) return
|
if (!progressRef.value) return
|
||||||
@@ -909,11 +390,7 @@ const handleProgressClick = (event: MouseEvent) => {
|
|||||||
tempProgressPercentage.value = percentage
|
tempProgressPercentage.value = percentage
|
||||||
|
|
||||||
const newTime = (percentage / 100) * Audio.value.duration
|
const newTime = (percentage / 100) * Audio.value.duration
|
||||||
|
seekTo(newTime)
|
||||||
setCurrentTime(newTime)
|
|
||||||
if (Audio.value.audio) {
|
|
||||||
Audio.value.audio.currentTime = newTime
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProgressDragMove = (event: MouseEvent) => {
|
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 offsetX = Math.max(0, Math.min(event.clientX - rect.left, rect.width))
|
||||||
const percentage = (offsetX / rect.width) * 100
|
const percentage = (offsetX / rect.width) * 100
|
||||||
const newTime = (percentage / 100) * Audio.value.duration
|
const newTime = (percentage / 100) * Audio.value.duration
|
||||||
|
seekTo(newTime)
|
||||||
setCurrentTime(newTime)
|
|
||||||
if (Audio.value.audio) {
|
|
||||||
Audio.value.audio.currentTime = newTime
|
|
||||||
}
|
|
||||||
|
|
||||||
isDraggingProgress.value = false
|
isDraggingProgress.value = false
|
||||||
window.removeEventListener('mousemove', handleProgressDragMove)
|
window.removeEventListener('mousemove', handleProgressDragMove)
|
||||||
@@ -960,22 +433,7 @@ const handleProgressDragStart = (event: MouseEvent) => {
|
|||||||
window.addEventListener('mouseup', handleProgressDragEnd)
|
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 maincolor = ref('var(--td-brand-color-5)')
|
||||||
const startmaincolor = ref('rgba(0, 0, 0, 1)')
|
const startmaincolor = ref('rgba(0, 0, 0, 1)')
|
||||||
const contrastTextColor = ref('rgba(0, 0, 0, .8)')
|
const contrastTextColor = ref('rgba(0, 0, 0, .8)')
|
||||||
@@ -1304,38 +762,37 @@ watch(showFullPlay, (val) => {
|
|||||||
/* 进度条样式 */
|
/* 进度条样式 */
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
--touch-range-height: 20px;
|
||||||
|
--play-line-height: 4px;
|
||||||
|
height: calc(var(--touch-range-height) + var(--play-line-height)); // 放大可点击区域,但保持视觉细
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// padding-top: 2px;
|
top: calc(var(--touch-range-height) / 2 * -1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
&:has(.progress-handle.dragging, *:hover) {
|
|
||||||
// margin-bottom: 0;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.progress-background {
|
// 视觉上的细轨道,垂直居中
|
||||||
|
.progress-background,
|
||||||
|
.progress-filled {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 100%;
|
height: var(--play-line-height);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-background {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-filled {
|
.progress-filled {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(to right, v-bind(startmaincolor), v-bind(maincolor) 80%);
|
background: linear-gradient(to right, v-bind(startmaincolor), v-bind(maincolor) 80%);
|
||||||
border-radius: 999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-handle {
|
.progress-handle {
|
||||||
@@ -1347,7 +804,6 @@ watch(showFullPlay, (val) => {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
// transition: opacity 0.2s ease;
|
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&: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 {
|
&:hover .progress-handle {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,20 @@ const menuList: MenuItem[] = [
|
|||||||
path: '/home/find'
|
path: '/home/find'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '本地',
|
name: '歌单',
|
||||||
icon: 'icon-music',
|
icon: 'icon-yanchu',
|
||||||
path: '/home/local'
|
path: '/home/songlist'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '最近',
|
name: '本地',
|
||||||
icon: 'icon-shijian',
|
icon: 'icon-shouye',
|
||||||
path: '/home/recent'
|
path: '/home/local'
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// name: '最近',
|
||||||
|
// icon: 'icon-shijian',
|
||||||
|
// path: '/home/recent'
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
const menuActive = ref(0)
|
const menuActive = ref(0)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'find',
|
name: 'find',
|
||||||
component: () => import('@renderer/views/music/find.vue')
|
component: () => import('@renderer/views/music/find.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'songlist',
|
||||||
|
name: 'songlist',
|
||||||
|
component: () => import('@renderer/views/music/songlist.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'local',
|
path: 'local',
|
||||||
name: 'local',
|
name: 'local',
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 80,
|
volume: 80,
|
||||||
url: ''
|
url: '',
|
||||||
|
eventInit: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// -------------------------------------------发布订阅逻辑------------------------------------------
|
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type ControlAudioState = {
|
|||||||
duration: number
|
duration: number
|
||||||
volume: number
|
volume: number
|
||||||
url: string
|
url: string
|
||||||
|
eventInit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SongList = playList
|
export type SongList = playList
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// 全局音频管理器,用于管理音频源和分析器
|
// 全局音频管理器,用于管理音频源和分析器
|
||||||
|
|
||||||
class AudioManager {
|
class AudioManager {
|
||||||
private static instance: AudioManager
|
private static instance: AudioManager
|
||||||
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
|
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
|
||||||
@@ -28,8 +29,15 @@ class AudioManager {
|
|||||||
context = new (window.AudioContext || (window as any).webkitAudioContext)()
|
context = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
source = context.createMediaElementSource(audioElement)
|
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)
|
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> {
|
export async function getSongRealUrl(song: SongList): Promise<string> {
|
||||||
try {
|
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()
|
const LocalUserDetail = LocalUserDetailStore()
|
||||||
// 通过统一的request方法获取真实的播放URL
|
// 通过统一的request方法获取真实的播放URL
|
||||||
|
|||||||
@@ -81,20 +81,7 @@ const fetchLocalPlaylistSongs = async () => {
|
|||||||
const result = await window.api.songList.getSongs(playlistInfo.value.id)
|
const result = await window.api.songList.getSongs(playlistInfo.value.id)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
songs.value = result.data.map((song: any) => ({
|
songs.value = result.data
|
||||||
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 || {}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 更新歌单信息中的歌曲总数
|
// 更新歌单信息中的歌曲总数
|
||||||
playlistInfo.value.total = songs.value.length
|
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>
|
<template>
|
||||||
<div class="recent-container">
|
<div class="recent-container">
|
||||||
|
<div>待开发 作者正在麻溜赶代码 奈何还有期中考 不要抽我呀!</div>
|
||||||
<!-- 页面标题和操作 -->
|
<!-- 页面标题和操作 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<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 {
|
:root {
|
||||||
--font-size: 30;
|
--font-size: 30;
|
||||||
--main-color: #73BCFC;
|
--main-color: #73bcfc;
|
||||||
--shadow-color: rgba(255, 255, 255, 0.5);
|
--shadow-color: rgba(255, 255, 255, 0.5);
|
||||||
|
--next-color: rgba(255, 255, 255, 0.68);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -166,6 +167,7 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
#lyric-text {
|
#lyric-text {
|
||||||
font-size: calc(var(--font-size) * 1px);
|
font-size: calc(var(--font-size) * 1px);
|
||||||
@@ -199,6 +201,75 @@
|
|||||||
transform: translateX(-100%);
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -309,8 +380,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main id="lyric-content">
|
<main id="lyric-content">
|
||||||
<span id="lyric-text">该歌曲暂无歌词</span>
|
<span id="lyric-text" style="display: none">该歌曲暂无歌词</span>
|
||||||
<span id="lyric-tran"></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>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
class LyricsWindow {
|
class LyricsWindow {
|
||||||
@@ -333,6 +414,12 @@
|
|||||||
this.winHeight = 0
|
this.winHeight = 0
|
||||||
// 临时变量
|
// 临时变量
|
||||||
// this.lyricIndex = -1;
|
// this.lyricIndex = -1;
|
||||||
|
// 每首歌起始基准索引,用于保证第一句总在左侧
|
||||||
|
this._baseIndex = null
|
||||||
|
// 上一索引与延迟更新队列(仅延迟“上一行->下一行”的替换)
|
||||||
|
this._lastIndex = -1
|
||||||
|
this._pendingUpnext = null // { wrapEl, textEl, tranEl, data }
|
||||||
|
this._lastLyricPayload = { lyric: [] }
|
||||||
// 初始化
|
// 初始化
|
||||||
this.restoreOptions()
|
this.restoreOptions()
|
||||||
this.menuClick()
|
this.menuClick()
|
||||||
@@ -340,14 +427,123 @@
|
|||||||
this.setupWindowDragListeners()
|
this.setupWindowDragListeners()
|
||||||
this.setupMutationObserver()
|
this.setupMutationObserver()
|
||||||
}
|
}
|
||||||
// 歌词切换动画
|
// 准备态渲染:展示第 1、2 句左右铺开,不标记 current/upnext
|
||||||
updateLyrics(content = '纯音乐,请欣赏', translation = '') {
|
renderPrepare() {
|
||||||
// document.startViewTransition(() => {
|
const payload = this._lastLyricPayload || { lyric: [] }
|
||||||
// this.lyricTextDom.innerHTML = content;
|
const lines = payload.lyric || []
|
||||||
// this.lyricTranDom.innerHTML = translation;
|
if (!Array.isArray(lines) || lines.length === 0) return
|
||||||
// });
|
const first = lines[0] || { content: '', tran: '' }
|
||||||
this.lyricTextDom.innerHTML = content
|
const second = lines[1] || { content: '', tran: '' }
|
||||||
this.lyricTranDom.innerHTML = translation
|
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() {
|
async restoreOptions() {
|
||||||
@@ -365,9 +561,18 @@
|
|||||||
changeOptions(options, callback = true) {
|
changeOptions(options, callback = true) {
|
||||||
if (!options) return
|
if (!options) return
|
||||||
const { fontSize, mainColor, shadowColor } = options
|
const { fontSize, mainColor, shadowColor } = options
|
||||||
document.documentElement.style.setProperty('--font-size', fontSize)
|
const root = document.documentElement
|
||||||
document.documentElement.style.setProperty('--main-color', mainColor)
|
root.style.setProperty('--font-size', fontSize)
|
||||||
document.documentElement.style.setProperty('--shadow-color', shadowColor)
|
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)
|
if (callback) window.electron.ipcRenderer.send('set-desktop-lyric-option', options)
|
||||||
}
|
}
|
||||||
// 菜单点击事件
|
// 菜单点击事件
|
||||||
@@ -442,6 +647,8 @@
|
|||||||
const [songName, songArtist] = title.split(' - ')
|
const [songName, songArtist] = title.split(' - ')
|
||||||
this.songNameDom.innerHTML = songName
|
this.songNameDom.innerHTML = songName
|
||||||
this.songArtistDom.innerHTML = songArtist
|
this.songArtistDom.innerHTML = songArtist
|
||||||
|
// 每首歌切换时重置基准索引,确保首句在左侧
|
||||||
|
this._baseIndex = null
|
||||||
this.updateLyrics(title)
|
this.updateLyrics(title)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -450,11 +657,55 @@
|
|||||||
this.parsedLyricsData(lyricData)
|
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) => {
|
window.electron.ipcRenderer.on('play-status-change', (_, status) => {
|
||||||
this.playDom.classList.toggle('hidden', status)
|
this.playDom.classList.toggle('hidden', status)
|
||||||
this.pauseDom.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) => {
|
window.electron.ipcRenderer.on('desktop-lyric-option-change', (_, options) => {
|
||||||
this.changeOptions(options, false)
|
this.changeOptions(options, false)
|
||||||
@@ -469,12 +720,17 @@
|
|||||||
parsedLyricsData(lyricData) {
|
parsedLyricsData(lyricData) {
|
||||||
if (!this.lyricContentDom || !this.lyricTextDom) return
|
if (!this.lyricContentDom || !this.lyricTextDom) return
|
||||||
const { index, lyric } = lyricData
|
const { index, lyric } = lyricData
|
||||||
// 更换文字
|
// 缓存全集合,等待 index 推送来决定显示侧(奇偶)
|
||||||
if (!lyric || index < 0) {
|
this._lastLyricPayload = { lyric }
|
||||||
if (lyric.length === 0) this.updateLyrics()
|
|
||||||
} else {
|
if (!lyric || lyric.length === 0) {
|
||||||
const { content, tran } = lyric[index]
|
this.updateLyrics()
|
||||||
this.updateLyrics(content, tran || '')
|
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