Compare commits

...

24 Commits

Author SHA1 Message Date
sqj
9545f32c81 update(v1.5.0):本次更新带来全新本地播放功能,支持精准匹配与批量操作,优化歌曲列表交互体验;新增桌面歌词双行展示,支持TTML歌词格式。同时修复了本地歌单播放、切歌控制等多个问题,性能与体验全面升级。 2025-11-24 21:12:50 +08:00
sqj
7945108243 feat(本地音乐): 改进本地音乐页面交互和样式
- 添加右键菜单功能支持播放、加入播放列表等操作
- 实现单击加入播放列表、双击播放的交互逻辑
- 优化按钮布局和样式,使用下拉菜单整合更多操作
- 调整歌曲列表样式,移除操作列,改为行点击交互
- 添加加载状态显示,优化匹配中的歌曲展示
2025-11-24 20:45:49 +08:00
sqj
6ce05d286f feat(播放器): 重构全局播放管理逻辑并优化本地音乐匹配功能
新增全局播放管理器模块,集中处理播放控制逻辑
为本地音乐添加精准匹配功能及批量匹配进度显示
优化歌曲列表UI样式和操作体验
修复音频控制事件重复监听的问题
2025-11-23 23:12:36 +08:00
star
8209d021de update(local.vue): 更改界面布局 2025-11-15 16:07:19 +08:00
sqj
a277cb7181 fix: 酷狗歌单无法播放,软件无法退出,全局播放进入设置无法下一曲 feat:本地播放 2025-11-15 13:26:42 +08:00
sqj
a9ad32e8ea 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:26:39 +08:00
sqj
ca3213d0b3 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 21:16:02 +08:00
sqj
7c7455786e 🚀feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 21:07:05 +08:00
sqj
68fb9bcec5 🚀feat: 添加桌面歌词功能,列表平台显示 本地日志优化 fix:大幅优化打包体积 2025-11-01 20:53:45 +08:00
sqj
54e2842b1b �feat: 添加桌面歌词功能,列表平台显示 fix:大幅优化打包体积 2025-11-01 20:15:43 +08:00
sqj
ce743e1b65 Merge branch 'main' of https://github.com/timeshiftsauce/CeruMusic 2025-10-31 01:18:19 +08:00
sqj
32c9fdbfeb feat: 1.歌曲播放器喜欢和下载功能,列表快速加入喜欢。2. 歌曲播放时自动 点击播放其他歌曲自动添加到下一曲播放而不是提到最前,屎山逻辑(bushi。fix: 对于副屏用户优化 全屏宽高限制 导致无法全屏问题。(嘿嘿摸鱼这么多天就更新这些吧周末看看能不能把桌面歌词搞了,容我学习亿下下) 2025-10-31 01:17:36 +08:00
时迁酱
9df236b2e0 Update contact information and sponsorship section 2025-10-30 18:40:29 +08:00
时迁酱
0988c71282 Update README.md 2025-10-30 18:39:28 +08:00
sqj
60881f7f48 feat docs 2025-10-25 20:39:54 +08:00
sqj
775f87aa86 feat docs 2025-10-25 20:14:30 +08:00
sqj
b1c471f15c fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:39 +08:00
sqj
f7ecfa1fa9 fix:修复歌单导入失败的问题,歌词翻译匹配逻辑 2025-10-14 21:44:08 +08:00
sqj
d44be6022a fix:优化滚动位置问题,某平台 歌单上限导入失败问题,优化包体积,修复歌曲下载失败 2025-10-11 22:54:10 +08:00
sqj
0c512bccff fix: 修复歌曲无法下载,支持更多音质选择 2025-10-09 20:25:27 +08:00
sqj
b07cc2359a fix: 修复歌曲播放缓存内存泄露问题 feat:歌曲播放出错自动切歌不是暂停 2025-10-09 01:47:07 +08:00
时迁酱
46756a8b09 Merge pull request #12 from GladerJ/main
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 16:51:53 +08:00
Glader
deb73fa789 修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
修复播放新歌曲失败时,原歌曲 UI 状态被错误覆盖的问题。
2025-10-08 15:15:30 +08:00
sqj
910ab1ff10 docs: add 赞助名单 2025-10-07 22:24:40 +08:00
81 changed files with 13059 additions and 19629 deletions

View File

@@ -30,7 +30,7 @@ jobs:
- name: Install Dependencies
run: |
npm i -g yarn
yarn install # 安装项目依赖
yarn # 安装项目依赖
- name: Build Electron App for windows
if: matrix.os == 'windows-latest' # 只在Windows上运行

View File

@@ -295,7 +295,11 @@ CeruMuisc/
## 联系方式
如有技术问题或合作意向(仅限技术交流),请通过 Gitee 私信联系项目维护者。
如有技术问题或合作意向
可通过如下方式联系
- QQ: 2115295703
- 微信13600973542
- 邮箱sqj@shiqianjiang.cn
## 项目开发者
@@ -357,8 +361,3 @@ CeruMuisc/
若您认可本项目的技术价值,欢迎通过以下方式支持开发者(仅用于项目技术维护与迭代):
<img src="assets/image-20250827175356006.png" alt="赞助方式1" style="zoom:33%;" /><img src="assets/image-20250827175547444.png" alt="赞助方式2" style="zoom: 33%;" />
## 联系
关于项目问题也可联系
邮箱sqj@shiqianjiang.cn

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

@@ -5,12 +5,16 @@
| 昵称 | 赞助金额 |
| :-------------------------: | :------: |
| **群友**:可惜情绪落泪零碎 | 6.66 |
| **群友**:🍀 | 5 |
| **群友**:🍀 | 5 |
| **群友**:涟漪 | 50 |
| **作者朋友** | 188 |
| **群友**:我叫阿狸 | 3 |
| RiseSun | 9.9 |
| **b站小友**:光牙阿普斯木兰 | 5 |
| 青禾 | 8.8 |
| li peng | 200 |
| **群友**XIZ | 3 |
| YL | 10 |
| **群友**way1437 | 50 |
据不完全统计 如有疏漏可联系sqj@shiqianjiang.cn

View File

@@ -46,7 +46,10 @@ features:
Ceru Music 是基于 Electron 和 Vue 开发的跨平台桌面音乐播放器工具,**仅提供插件运行框架与播放功能**,不直接存储、提供任何音乐源文件。用户需通过自行选择、安装合规插件获取音乐相关数据,项目旨在为开发者提供桌面应用技术实践与学习案例,为用户提供合规的音乐播放工具框架。
<img src="./assets/image-20250827175023917.png" alt="image-20250827175023917" style="zoom: 33%;" /><img src="./assets/image-20250827175109430.png" alt="image-20250827175109430" style="zoom:33%;" />
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 2rem">
<img src="./assets/image-20251003173109619.png" alt="image-20251003173109619"/>
<img src= "./assets/image-20251003173654569.png">
</div>
## 技术栈

View File

@@ -6,8 +6,12 @@ asar: true
files:
- '!**/.vscode/*'
- '!src/*'
- '!website/*'
- '!scripts/*'
- '!assets/*'
- '!docs/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md,.idea,.kiro,.codebuddy}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
@@ -81,4 +85,4 @@ publish:
provider: generic
url: https://update.ceru.shiqianjiang.cn
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
mirror: https://npmmirror.com/mirrors/electron/

View File

@@ -8,7 +8,6 @@ import { TDesignResolver } from '@tdesign-vue-next/auto-import-resolver'
import wasm from 'vite-plugin-wasm'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import topLevelAwait from 'vite-plugin-top-level-await'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
@@ -16,6 +15,14 @@ export default defineConfig({
alias: {
'@common': resolve('src/common')
}
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/main/index.ts'),
lyric: resolve(__dirname, 'src/web/lyric.html')
}
}
}
},
preload: {

15117
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ceru-music",
"version": "1.4.0",
"version": "1.5.0",
"description": "一款简洁优雅的音乐播放器",
"main": "./out/main/index.js",
"author": "sqj,wldss,star",
@@ -47,9 +47,8 @@
"@pixi/filter-bulge-pinch": "^5.1.1",
"@pixi/filter-color-matrix": "^7.4.3",
"@pixi/sprite": "^7.4.3",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/howler": "^2.2.12",
"@types/needle": "^3.3.0",
"NeteaseCloudMusicApi": "^4.27.0",
"animate.css": "^4.1.1",
"axios": "^1.11.0",
"color-extraction": "^1.0.8",
@@ -57,8 +56,7 @@
"dompurify": "^3.2.6",
"electron-log": "^5.4.3",
"electron-updater": "^6.3.9",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"howler": "^2.2.4",
"hpagent": "^1.2.0",
"iconv-lite": "^0.7.0",
"jss": "^10.10.0",
@@ -69,9 +67,9 @@
"mitt": "^3.0.1",
"needle": "^3.3.1",
"node-fetch": "2",
"node-id3": "^0.2.9",
"node-taglib-sharp": "^6.0.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"pinia-plugin-persistedstate": "4.5.0",
"tdesign-icons-vue-next": "^0.4.1",
"tdesign-vue-next": "^1.15.2",
"vue-router": "^4.5.1",
@@ -113,4 +111,4 @@
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.3"
}
}
}

692
playlist-converter.html Normal file
View File

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

View File

@@ -0,0 +1,10 @@
export interface lyricConfig {
fontSize: number
mainColor: string
shadowColor: string
// 窗口位置
x?: number
y?: number
width?: number
height?: number
}

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
export const QUALITY_ORDER = [
'master',
'atmos_plus',
'atmos',
'hires',
'flac24bit',
'flac',
'320k',
'192k',
'128k'
] as const
export type KnownQuality = (typeof QUALITY_ORDER)[number]
export type QualityInput = KnownQuality | string | { type: string; size?: string }
const DISPLAY_NAME_MAP: Record<string, string> = {
'128k': '标准',
'192k': '高品',
'320k': '超高',
flac: '无损',
flac24bit: '超高解析',
hires: '高清臻音',
atmos: '全景环绕',
atmos_plus: '全景增强',
master: '超清母带'
}
/**
* 统一获取音质中文显示名称
*/
export function getQualityDisplayName(quality: QualityInput | null | undefined): string {
if (!quality) return ''
const type = typeof quality === 'object' ? (quality as any).type : quality
return DISPLAY_NAME_MAP[type] || String(type || '')
}
/**
* 比较两个音质优先级(返回负数表示 a 优于 b
*/
export function compareQuality(aType: string, bType: string): number {
const ia = QUALITY_ORDER.indexOf(aType as KnownQuality)
const ib = QUALITY_ORDER.indexOf(bType as KnownQuality)
const va = ia === -1 ? QUALITY_ORDER.length : ia
const vb = ib === -1 ? QUALITY_ORDER.length : ib
return va - vb
}
/**
* 规范化 types兼容 string 与 {type,size}
*/
export function normalizeTypes(
types: Array<string | { type: string; size?: string }> | null | undefined
): string[] {
if (!types || !Array.isArray(types)) return []
return types
.map((t) => (typeof t === 'object' ? (t as any).type : t))
.filter((t): t is string => Boolean(t))
}
/**
* 获取数组中最高音质类型
*/
export function getHighestQualityType(
types: Array<string | { type: string; size?: string }> | null | undefined
): string | null {
const arr = normalizeTypes(types)
if (!arr.length) return null
return arr.sort(compareQuality)[0]
}
/**
* 构建并按优先级排序的 [{type, size}] 列表
* 支持传入:
* - 数组:[{type,size}]
* - _types 映射:{ [type]: { size } }
*/
export function buildQualityFormats(
input:
| Array<{ type: string; size?: string }>
| Record<string, { size?: string }>
| null
| undefined
): Array<{ type: string; size?: string }> {
if (!input) return []
let list: Array<{ type: string; size?: string }>
if (Array.isArray(input)) {
list = input.map((i) => ({ type: i.type, size: i.size }))
} else {
list = Object.keys(input).map((k) => ({ type: k, size: input[k]?.size }))
}
return list.sort((a, b) => compareQuality(a.type, b.type))
}

107
src/main/events/index.ts Normal file
View File

@@ -0,0 +1,107 @@
import InitPluginService from './plugins'
import '../services/musicSdk/index'
import aiEvents from '../events/ai'
// import initLocalMusicEvents from './localMusic'
import { app, powerSaveBlocker } from 'electron'
import { type BrowserWindow, ipcMain } from 'electron'
export default function InitEventServices(mainWindow: BrowserWindow) {
InitPluginService()
aiEvents(mainWindow)
// initLocalMusicEvents()
basisEvent(mainWindow)
}
function basisEvent(mainWindow: BrowserWindow) {
let psbId: number | null = null
// 复用主进程创建的托盘
let tray: any = (global as any).__ceru_tray__ || null
let isQuitting = false
// 托盘菜单与图标由主进程统一创建,这里不再重复创建
// 播放/暂停由主进程托盘菜单触发 'music-control' 事件
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
mainWindow.minimize()
})
ipcMain.on('window-maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
ipcMain.on('window-close', () => {
mainWindow.close()
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
tray = (global as any).__ceru_tray__ || tray
if (tray && tray.displayBalloon) {
tray.displayBalloon({
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
// 阻止系统息屏 IPC开启/关闭)
ipcMain.handle('power-save-blocker:start', () => {
if (psbId == null) {
psbId = powerSaveBlocker.start('prevent-display-sleep')
}
return psbId
})
ipcMain.handle('power-save-blocker:stop', () => {
if (psbId != null && powerSaveBlocker.isStarted(psbId)) {
powerSaveBlocker.stop(psbId)
}
psbId = null
return true
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
}

View 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 || '清空失败' }
}
})

176
src/main/events/lyric.ts Normal file
View File

@@ -0,0 +1,176 @@
import { BrowserWindow, ipcMain, screen } from 'electron'
import { isAbsolute, relative, resolve } from 'path'
import { lyricConfig } from '@common/types/config'
import { configManager } from '../services/ConfigManager'
import lyricWindow from '../windows/lyric-window'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
/**
* 歌词相关 IPC
*/
const initLyricIpc = (mainWin?: BrowserWindow | null): void => {
// const mainWin = mainWindow.getWin()
const lyricWin = lyricWindow.getWin()
// 切换桌面歌词
ipcMain.on('change-desktop-lyric', (_event, val: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
if (val) {
lyricWin?.show()
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
} else lyricWin?.hide()
})
ipcMain.on('win-show', () => {
mainWin?.show()
})
// 音乐名称更改
ipcMain.on('play-song-change', (_, title) => {
if (!title) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-song-change', title)
})
// 音乐歌词更改
ipcMain.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-change', lyricData)
})
// 当前行索引变化(用于立即高亮切换)
ipcMain.on('play-lyric-index', (_, index: number) => {
if (index === undefined || index === null) return
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-lyric-index', index)
})
// 当前行进度(用于控制 30% 时机的延迟替换)
ipcMain.on('play-lyric-progress', (_, payload: { index: number; progress: number }) => {
if (!payload || !lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed())
return
lyricWin?.webContents.send('play-lyric-progress', payload)
})
// 播放状态更改(播放/暂停)
ipcMain.on('play-status-change', (_, status: boolean) => {
if (!lyricWin || lyricWin?.isDestroyed() || lyricWin?.webContents?.isDestroyed()) return
lyricWin?.webContents.send('play-status-change', status)
})
// 获取窗口位置
ipcMain.handle('get-window-bounds', () => {
return lyricWin?.getBounds()
})
// 同步获取窗口位置(回退)
ipcMain.on('get-window-bounds-sync', (event) => {
event.returnValue = lyricWin?.getBounds()
})
// 获取屏幕尺寸
ipcMain.handle('get-screen-size', () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
return { width, height }
})
// 同步获取屏幕尺寸(回退)
ipcMain.on('get-screen-size-sync', (event) => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize
event.returnValue = { width, height }
})
// 移动窗口
ipcMain.on('move-window', (_, x, y, width, height) => {
lyricWin?.setBounds({ x, y, width, height })
// 保存配置
lyricStore.set({ ...lyricStore.get(), x, y, width, height })
// 保持置顶
lyricWin?.setAlwaysOnTop(true, 'screen-saver')
})
// 更新高度
ipcMain.on('update-window-height', (_, height) => {
if (!lyricWin) return
const { width } = lyricWin.getBounds()
// 更新窗口高度
lyricWin.setBounds({ width, height })
})
// 获取配置
ipcMain.handle('get-desktop-lyric-option', () => {
return lyricStore.get()
})
// 同步获取配置(用于 invoke 不可用的回退)
ipcMain.on('get-desktop-lyric-option-sync', (event) => {
event.returnValue = lyricStore.get()
})
// 保存配置
ipcMain.on('set-desktop-lyric-option', (_, option, callback: boolean = false) => {
lyricStore.set(option)
// 触发窗口更新
if (callback && lyricWin) {
lyricWin.webContents.send('desktop-lyric-option-change', option)
}
mainWin?.webContents.send('desktop-lyric-option-change', option)
})
// 发送主程序事件
ipcMain.on('send-main-event', (_, name, val) => {
mainWin?.webContents.send(name, val)
})
// 关闭桌面歌词
ipcMain.on('closeDesktopLyric', () => {
lyricWin?.hide()
mainWin?.webContents.send('closeDesktopLyric')
})
// 锁定/解锁桌面歌词
let lyricLockState = false
ipcMain.on('toogleDesktopLyricLock', (_, isLock: boolean) => {
if (!lyricWin) return
lyricLockState = !!isLock
// 是否穿透
if (lyricLockState) {
lyricWin.setIgnoreMouseEvents(true, { forward: true })
} else {
lyricWin.setIgnoreMouseEvents(false)
}
// 广播到桌面歌词窗口与主窗口,保持两端状态一致
lyricWin.webContents.send('toogleDesktopLyricLock', lyricLockState)
mainWin?.webContents.send('toogleDesktopLyricLock', lyricLockState)
})
// 查询当前桌面歌词锁定状态
ipcMain.handle('get-lyric-lock-state', () => lyricLockState)
// 检查是否是子文件夹
ipcMain.handle('check-if-subfolder', (_, localFilesPath: string[], selectedDir: string) => {
const resolvedSelectedDir = resolve(selectedDir)
const allPaths = localFilesPath.map((p) => resolve(p))
return allPaths.some((existingPath) => {
const relativePath = relative(existingPath, resolvedSelectedDir)
return relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)
})
})
}
export default initLyricIpc

View File

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

View File

@@ -0,0 +1,80 @@
import { ipcMain } from 'electron'
import pluginService from '../services/plugin'
function PluginEvent() {
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
}
export default function InitPluginService() {
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
PluginEvent()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
}

View File

@@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import ManageSongList, { SongListError } from '../services/songList/ManageSongList'
import type { SongList, Songs } from '@common/types/songList'
import { configManager } from '../services/ConfigManager'
// 创建新歌单
ipcMain.handle(
@@ -21,6 +22,31 @@ ipcMain.handle(
}
)
// 喜欢歌单ID持久化
ipcMain.handle('songlist:get-favorites-id', async () => {
try {
const id = configManager.get<string>('favoritesHashId', '')
return { success: true, data: id || null }
} catch (error) {
console.error('获取喜欢歌单ID失败:', error)
return { success: false, error: '获取喜欢歌单ID失败' }
}
})
ipcMain.handle('songlist:set-favorites-id', async (_, id: string) => {
try {
if (!id || typeof id !== 'string' || !id.trim()) {
return { success: false, error: '无效的歌单ID' }
}
configManager.set('favoritesHashId', id.trim())
const ok = configManager.saveConfig()
return { success: ok, message: ok ? '已保存喜欢歌单ID' : '保存失败' }
} catch (error) {
console.error('设置喜欢歌单ID失败:', error)
return { success: false, error: '设置喜欢歌单ID失败' }
}
})
// 获取所有歌单
ipcMain.handle('songlist:get-all', async () => {
try {

View File

@@ -1,12 +1,31 @@
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, screen } from 'electron'
import {
app,
shell,
BrowserWindow,
ipcMain,
screen,
Rectangle,
Display,
Tray,
Menu
} from 'electron'
import { configManager } from './services/ConfigManager'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/logo.png?asset'
import path from 'node:path'
import pluginService from './services/plugin'
import aiEvents from './events/ai'
import './services/musicSdk/index'
import InitEventServices from './events'
import lyricWindow from './windows/lyric-window'
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import initLyricIpc from './events/lyric'
import { initPluginNotice } from './events/pluginNotice'
import './events/localMusic'
// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock()
@@ -25,27 +44,34 @@ if (!gotTheLock) {
})
}
// import wy from './utils/musicSdk/wy/index'
// import kg from './utils/musicSdk/kg/index'
// wy.hotSearch.getList().then((res) => {
// console.log(res)
// })
// kg.hotSearch.getList().then((res) => {
// console.log(res)
// })
let tray: Tray | null = null
let mainWindow: BrowserWindow | null = null
let isQuitting = false
let tray: Tray | null = null
let trayLyricLocked = false
function createTray(): void {
// 创建系统托盘
const trayIconPath = path.join(__dirname, '../../resources/logo.png')
tray = new Tray(trayIconPath)
function updateTrayMenu() {
const lyricWin = lyricWindow.getWin()
const isVisible = !!lyricWin && lyricWin.isVisible()
const toggleLyricLabel = isVisible ? '隐藏桌面歌词' : '显示桌面歌词'
const toggleLockLabel = trayLyricLocked ? '解锁桌面歌词' : '锁定桌面歌词'
// 创建托盘菜单
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
label: toggleLyricLabel,
click: () => {
const target = !isVisible
ipcMain.emit('change-desktop-lyric', null, target)
}
},
{
label: toggleLockLabel,
click: () => {
const next = !trayLyricLocked
ipcMain.emit('toogleDesktopLyricLock', null, next)
}
},
{ type: 'separator' },
{
label: '显示主窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
@@ -56,8 +82,6 @@ function createTray(): void {
{
label: '播放/暂停',
click: () => {
// 这里可以添加播放控制逻辑
console.log('music-control')
mainWindow?.webContents.send('music-control')
}
},
@@ -65,46 +89,102 @@ function createTray(): void {
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
}
}
])
tray?.setContextMenu(contextMenu)
}
tray.setContextMenu(contextMenu)
function setupTray() {
// 全局单例防重复(热重载/多次执行保护)
const g: any = global as any
if (g.__ceru_tray__) {
try {
g.__ceru_tray__.destroy()
} catch {}
g.__ceru_tray__ = null
}
if (tray) {
try {
tray.destroy()
} catch {}
tray = null
}
const iconPath = path.join(__dirname, '../../resources/logo.ico')
tray = new Tray(iconPath)
tray.setToolTip('Ceru Music')
updateTrayMenu()
// 双击托盘图标显示窗口
// 左键单击切换主窗口显示
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
if (!mainWindow) return
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
})
// 防重复注册 IPC 监听(仅注册一次)
if (!g.__ceru_tray_ipc_bound__) {
ipcMain.on('toogleDesktopLyricLock', (_e, isLock: boolean) => {
trayLyricLocked = !!isLock
updateTrayMenu()
})
ipcMain.on('change-desktop-lyric', () => {
updateTrayMenu()
})
g.__ceru_tray_ipc_bound__ = true
}
// 记录全局托盘句柄
g.__ceru_tray__ = tray
app.once('before-quit', () => {
try {
tray?.destroy()
} catch {}
tray = null
g.__ceru_tray__ = null
})
}
/**
* 根据窗口当前所在的显示器,动态更新窗口的最大尺寸限制。
* 这样可以确保窗口在任何显示器上都能正常最大化或全屏。
* @param {BrowserWindow} win - 要更新的窗口实例
*/
function updateWindowMaxLimits(win: BrowserWindow | null): void {
if (!win) return
// 1. 获取窗口的当前边界 (bounds)
const currentBounds: Rectangle = win.getBounds()
// 2. 查找包含该边界的显示器
const currentDisplay: Display = screen.getDisplayMatching(currentBounds)
// 3. 获取该显示器的完整尺寸 (full screen size)
const { width: currentScreenWidth, height: currentScreenHeight } = currentDisplay.size
// 4. 应用新的最大尺寸限制
// 移除 maxWidth/maxHeight 上的硬限制,使其能够最大化到当前屏幕的尺寸。
// 注意:设置为 0, 0 意味着没有最小限制,我们只关注最大限制。
win.setMaximumSize(currentScreenWidth, currentScreenHeight)
}
function createWindow(): void {
// return
// 获取保存的窗口位置和大小
const savedBounds = configManager.getWindowBounds()
// 获取屏幕尺寸
const primaryDisplay = screen.getPrimaryDisplay()
// 使用完整屏幕尺寸而不是工作区域,以支持真正的全屏模式
const { width: screenWidth, height: screenHeight } = primaryDisplay.size
// 默认窗口配置
const defaultOptions = {
width: 1100,
height: 750,
minWidth: 1100,
minHeight: 670,
maxWidth: screenWidth,
maxHeight: screenHeight,
show: false,
center: !savedBounds, // 如果有保存的位置,则不居中
autoHideMenuBar: true,
@@ -130,24 +210,30 @@ function createWindow(): void {
mainWindow = new BrowserWindow(defaultOptions)
if (process.platform == 'darwin') mainWindow.setWindowButtonVisibility(false)
// 监听窗口移动和调整大小事件,保存窗口位置和大小
// ⚠️ 关键修改 2: 监听 'moved' 事件,动态更新最大尺寸
mainWindow.on('moved', () => {
// 当窗口移动时,确保最大尺寸限制随屏幕变化
updateWindowMaxLimits(mainWindow)
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
configManager.saveWindowBounds(bounds)
}
})
// ⚠️ 关键修改 3: 窗口创建后立即应用一次最大尺寸限制
updateWindowMaxLimits(mainWindow)
mainWindow.on('resized', () => {
if (mainWindow && !mainWindow.isMaximized() && !mainWindow.isFullScreen()) {
const bounds = mainWindow.getBounds()
// 获取当前屏幕尺寸
const { screen } = require('electron')
// 获取当前屏幕尺寸 (已在文件顶部导入 screen无需 require)
const currentDisplay = screen.getDisplayMatching(bounds)
// 使用 workAreaSize 避免窗口超出任务栏/Dock
const { width: screenWidth, height: screenHeight } = currentDisplay.workAreaSize
// 确保窗口不超过屏幕尺寸
// 确保窗口不超过屏幕工作区域尺寸
let needResize = false
const newBounds = { ...bounds }
@@ -173,29 +259,12 @@ function createWindow(): void {
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
// 阻止窗口关闭,改为隐藏到系统托盘
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow?.hide()
// 显示托盘通知
if (tray) {
tray.displayBalloon({
iconType: 'info',
title: 'Ceru Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
}
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url).then()
return { action: 'deny' }
})
InitEventServices(mainWindow)
initPluginNotice(mainWindow)
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
@@ -205,80 +274,6 @@ function createWindow(): void {
}
}
ipcMain.handle('service-plugin-selectAndAddPlugin', async (_, type): Promise<any> => {
try {
return await pluginService.selectAndAddPlugin(type)
} catch (error: any) {
console.error('Error selecting and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-downloadAndAddPlugin', async (_, url, type): Promise<any> => {
try {
return await pluginService.downloadAndAddPlugin(url, type)
} catch (error: any) {
console.error('Error downloading and adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-addPlugin', async (_, pluginCode, pluginName): Promise<any> => {
try {
return await pluginService.addPlugin(pluginCode, pluginName)
} catch (error: any) {
console.error('Error adding plugin:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginById', async (_, id): Promise<any> => {
try {
return pluginService.getPluginById(id)
} catch (error: any) {
console.error('Error getting plugin by id:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-loadAllPlugins', async (): Promise<any> => {
try {
// 使用新的 getPluginsList 方法,但保持 API 兼容性
return await pluginService.getPluginsList()
} catch (error: any) {
console.error('Error loading all plugins:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-getPluginLog', async (_, pluginId): Promise<any> => {
try {
return await pluginService.getPluginLog(pluginId)
} catch (error: any) {
console.error('Error getting plugin log:', error)
return { error: error.message }
}
})
ipcMain.handle('service-plugin-uninstallPlugin', async (_, pluginId): Promise<any> => {
try {
return await pluginService.uninstallPlugin(pluginId)
} catch (error: any) {
console.error('Error uninstalling plugin:', error)
return { error: error.message }
}
})
// 获取应用版本号
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
aiEvents(mainWindow)
import './events/musicCache'
import './events/songList'
import './events/directorySettings'
import './events/pluginNotice'
import { registerAutoUpdateEvents, initAutoUpdateForWindow } from './events/autoUpdate'
// This method will be called when Electron has finished
@@ -295,16 +290,6 @@ app.whenReady().then(() => {
app.setName('澜音')
}
setTimeout(async () => {
// 初始化插件系统
try {
await pluginService.initializePlugins()
console.log('插件系统初始化完成')
} catch (error) {
console.error('插件系统初始化失败:', error)
}
}, 1000)
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
@@ -312,63 +297,11 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// 窗口控制 IPC 处理
ipcMain.on('window-minimize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.minimize()
}
})
ipcMain.on('window-maximize', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
}
})
ipcMain.on('window-close', () => {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.close()
}
})
// Mini 模式 IPC 处理 - 最小化到系统托盘
ipcMain.on('window-mini-mode', (_, isMini) => {
if (mainWindow) {
if (isMini) {
// 进入 Mini 模式:隐藏窗口到系统托盘
mainWindow.hide()
// 显示托盘通知(可选)
if (tray) {
tray.displayBalloon({
title: '澜音 Music',
content: '已最小化到系统托盘啦,点击托盘图标可重新打开~'
})
}
} else {
// 退出 Mini 模式:显示窗口
mainWindow.show()
mainWindow.focus()
}
}
})
// 全屏模式 IPC 处理
ipcMain.on('window-toggle-fullscreen', () => {
if (mainWindow) {
const isFullScreen = mainWindow.isFullScreen()
mainWindow.setFullScreen(!isFullScreen)
}
})
createWindow()
createTray()
lyricWindow.create()
initLyricIpc(mainWindow)
// 仅在主进程初始化一次托盘
setupTray()
// 注册自动更新事件
registerAutoUpdateEvents()
@@ -394,13 +327,7 @@ app.whenReady().then(() => {
// 当所有窗口关闭时不退出应用,因为我们有系统托盘
app.on('window-all-closed', () => {
// 在 macOS 上,应用通常会保持活跃状态
// 在其他平台上,我们也保持应用运行,因为有系统托盘
})
// 应用退出前的清理
app.on('before-quit', () => {
isQuitting = true
// 保持应用常驻,通过系统托盘管理
})
// In this file you can include the rest of your app's specific main process
@@ -408,57 +335,14 @@ app.on('before-quit', () => {
let ping: NodeJS.Timeout
function startPing() {
let interval = 3000
// 已迁移到 Howler不再使用 DOM <audio> 轮询。
// 如需主进程感知播放结束,请在渲染进程通过 IPC 主动通知。
if (ping) {
clearInterval(ping)
}
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res.duration - res.currentTime <= 20) {
clearInterval(ping)
interval = 500
ping = setInterval(() => {
if (mainWindow) {
mainWindow.webContents
.executeJavaScript(
`
(function() {
const audio = document.getElementById("globaAudio");
if(!audio) return { playing:false, ended: false };
if(audio.ended) return { playing:false, ended: true };
return { playing: !audio.paused, ended: false, currentTime: audio.currentTime, duration: audio.duration };
})()
`
)
.then((res) => {
console.log(res)
if (res && res.ended) {
mainWindow?.webContents.send('song-ended')
console.log('next song')
clearInterval(ping)
}
})
.catch((err) => console.warn(err))
}
}, interval)
}
})
.catch((err) => console.warn(err))
}
}, interval)
// 保留占位,避免调用方报错;不再做任何轮询。
// 可在此处监听自定义 IPC 事件以扩展行为。
clearInterval(ping)
}, 1000)
}

47
src/main/logger/index.ts Normal file
View File

@@ -0,0 +1,47 @@
// 日志输出
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import { app } from 'electron'
import log from 'electron-log'
// 日志文件路径
const logDir = join(app.getPath('logs'))
// 是否存在日志目录
if (!existsSync(logDir)) mkdirSync(logDir)
// 获取日期 - YYYY-MM-DD
const dateString = new Date().toISOString().slice(0, 10)
const logFilePath = join(logDir, `${dateString}.log`)
console.log(logFilePath, '546444444444444444444444444444444444')
// 配置日志系统
log.transports.console.useStyles = true // 颜色输出
log.transports.file.level = 'info' // 仅记录 info 及以上级别
log.transports.file.resolvePathFn = (): string => logFilePath // 日志文件路径
log.transports.file.maxSize = 2 * 1024 * 1024 // 文件最大 2MB
// 日志格式化
// log.transports.file.format = "[{y}-{m}-{d} {h}:{i}:{s}] [{level}] [{scope}] {text}";
// 绑定默认事件
const defaultLog = log.scope('default')
console.log = defaultLog.log
console.info = defaultLog.info
console.warn = defaultLog.warn
console.error = defaultLog.error
// 分作用域导出
export { defaultLog }
export const ipcLog = log.scope('ipc')
export const trayLog = log.scope('tray')
export const thumbarLog = log.scope('thumbar')
export const storeLog = log.scope('store')
export const updateLog = log.scope('update')
export const systemLog = log.scope('system')
export const configLog = log.scope('config')
export const windowsLog = log.scope('windows')
export const processLog = log.scope('process')
export const preloadLog = log.scope('preload')
export const rendererLog = log.scope('renderer')
export const shortcutLog = log.scope('shortcut')
export const serverLog = log.scope('server')

View File

@@ -60,6 +60,7 @@ export class ConfigManager {
// 设置配置项
public set<T>(key: string, value: T): void {
this.config[key] = value
this.saveConfig()
}
// 删除配置项

View File

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

View File

@@ -493,7 +493,7 @@ class CeruMusicPluginHost {
}, timeout)
try {
console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 发起请求: ${options.method || 'GET'} ${url}`)
const fetchOptions = {
method: 'GET',
@@ -504,7 +504,7 @@ class CeruMusicPluginHost {
const response = await fetch(url, fetchOptions)
clearTimeout(timeoutId)
console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
// console.log(`${CONSTANTS.LOG_PREFIX} 请求响应: ${response.status} ${response.statusText}`)
const body = await this._parseResponseBody(response)
const headers = this._extractHeaders(response)
@@ -515,11 +515,11 @@ class CeruMusicPluginHost {
headers
}
console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
url,
status: response.status,
bodyType: typeof body
})
// console.log(`${CONSTANTS.LOG_PREFIX} 请求完成:`, {
// url,
// status: response.status,
// bodyType: typeof body
// })
return result
} catch (error: any) {

View File

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

View File

@@ -1,6 +1,4 @@
import NodeID3 from 'node-id3'
import ffmpegStatic from 'ffmpeg-static'
import ffmpeg from 'fluent-ffmpeg'
import { File, Picture, Id3v2Settings } from 'node-taglib-sharp'
import path from 'node:path'
import axios from 'axios'
import fs from 'fs'
@@ -75,6 +73,32 @@ function formatTimestamp(timeMs: number): string {
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`
}
// 根据图片 URL 和 Content-Type 解析扩展名,默认返回 .jpg
function resolveCoverExt(imgUrl: string, contentType?: string): string {
const validExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.bmp'])
let urlExt: string | undefined
try {
const pathname = new URL(imgUrl).pathname
const i = pathname.lastIndexOf('.')
if (i !== -1) {
urlExt = pathname.substring(i).toLowerCase()
}
} catch {}
if (urlExt && validExts.has(urlExt)) {
return urlExt === '.jpeg' ? '.jpg' : urlExt
}
if (contentType) {
if (contentType.includes('image/png')) return '.png'
if (contentType.includes('image/webp')) return '.webp'
if (contentType.includes('image/jpeg') || contentType.includes('image/jpg')) return '.jpg'
if (contentType.includes('image/bmp')) return '.bmp'
}
return '.jpg'
}
/**
* 转换新格式:[开始时间,持续时间](开始时间,字符持续时间,0)字符
*/
@@ -156,265 +180,6 @@ function convertOldFormat(timestamp: string, content: string): string {
return `[${timestamp}]${convertedContent}`
}
// 写入音频标签的辅助函数
async function writeAudioTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入音频标签:', filePath, tagWriteOptions, songInfo)
// 获取文件扩展名来判断格式
const fileExtension = path.extname(filePath).toLowerCase().substring(1)
console.log('文件格式:', fileExtension)
// 根据文件格式选择不同的标签写入方法
if (fileExtension === 'mp3') {
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
} else if (['flac', 'ogg', 'opus'].includes(fileExtension)) {
await writeVorbisCommentTags(filePath, songInfo, tagWriteOptions)
} else if (['m4a', 'mp4', 'aac'].includes(fileExtension)) {
await writeMP4Tags(filePath, songInfo, tagWriteOptions)
} else {
console.warn('不支持的音频格式:', fileExtension)
// 尝试使用 NodeID3 作为后备方案
await writeMP3Tags(filePath, songInfo, tagWriteOptions)
}
} catch (error) {
console.error('写入音频标签时发生错误:', error)
throw error
}
}
// MP3 格式标签写入
async function writeMP3Tags(filePath: string, songInfo: any, tagWriteOptions: any) {
const tags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
tags.title = songInfo.name || ''
tags.artist = songInfo.singer || ''
tags.album = songInfo.albumName || ''
tags.year = songInfo.year || ''
tags.genre = songInfo.genre || ''
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
tags.unsynchronisedLyrics = {
language: 'chi',
shortText: 'Lyrics',
text: convertedLrc
}
}
// 写入封面
if (tagWriteOptions.cover && songInfo.img) {
try {
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
tags.image = {
mime: 'image/jpeg',
type: {
id: 3,
name: 'front cover'
},
description: 'Cover',
imageBuffer: Buffer.from(coverResponse.data)
}
}
} catch (coverError) {
console.warn('获取封面失败:', coverError)
}
}
// 写入标签到文件
if (Object.keys(tags).length > 0) {
const success = NodeID3.write(tags, filePath)
if (success) {
console.log('MP3音频标签写入成功:', filePath)
} else {
console.warn('MP3音频标签写入失败:', filePath)
}
}
}
// FLAC/OGG 格式标签写入 (使用 Vorbis Comment)
async function writeVorbisCommentTags(filePath: string, songInfo: any, tagWriteOptions: any) {
try {
console.log('开始写入 FLAC 标签:', filePath)
// 准备新的标签数据
const newTags: any = {}
// 写入基础信息
if (tagWriteOptions.basicInfo) {
if (songInfo.name) newTags.TITLE = songInfo.name
if (songInfo.singer) newTags.ARTIST = songInfo.singer
if (songInfo.albumName) newTags.ALBUM = songInfo.albumName
if (songInfo.year) newTags.DATE = songInfo.year.toString()
if (songInfo.genre) newTags.GENRE = songInfo.genre
}
// 写入歌词
if (tagWriteOptions.lyrics && songInfo.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
newTags.LYRICS = convertedLrc
}
console.log('准备写入的标签:', newTags)
// 使用 ffmpeg-static 写入 FLAC 标签
if (path.extname(filePath).toLowerCase() === '.flac') {
await writeFLACTagsWithFFmpeg(filePath, newTags, songInfo, tagWriteOptions)
} else {
console.warn('暂不支持该格式的标签写入:', path.extname(filePath))
}
} catch (error) {
console.error('写入 Vorbis Comment 标签失败:', error)
throw error
}
}
// 使用 fluent-ffmpeg 写入 FLAC 标签
async function writeFLACTagsWithFFmpeg(
filePath: string,
tags: any,
songInfo: any,
tagWriteOptions: any
) {
let tempOutputPath: string | null = null
let tempCoverPath: string | null = null
try {
if (!ffmpegStatic) {
throw new Error('ffmpeg-static 不可用')
}
ffmpeg.setFfmpegPath(ffmpegStatic.replace('app.asar', 'app.asar.unpacked'))
// 创建临时输出文件
tempOutputPath = filePath + '.temp.flac'
// 创建 fluent-ffmpeg 实例
let command = ffmpeg(filePath)
.audioCodec('copy') // 复制音频编解码器,不重新编码
.output(tempOutputPath)
// 添加元数据标签
for (const [key, value] of Object.entries(tags)) {
if (value) {
// fluent-ffmpeg 会自动处理特殊字符转义
command = command.outputOptions(['-metadata', `${key}=${value}`])
}
}
// 处理封面
if (tagWriteOptions.cover && songInfo.img) {
try {
console.log('开始下载封面:', songInfo.img)
const coverResponse = await axios({
method: 'GET',
url: songInfo.img,
responseType: 'arraybuffer',
timeout: 10000
})
if (coverResponse.data) {
// 保存临时封面文件
tempCoverPath = path.join(path.dirname(filePath), 'temp_cover.jpg')
await fsPromise.writeFile(tempCoverPath, Buffer.from(coverResponse.data))
// 添加封面作为输入
command = command.input(tempCoverPath).outputOptions([
'-map',
'0:a', // 映射原始文件的音频流
'-map',
'1:v', // 映射封面的视频流
'-c:v',
'copy', // 复制视频编解码器
'-disposition:v:0',
'attached_pic' // 设置为附加图片
])
console.log('封面已添加到命令中')
}
} catch (coverError) {
console.warn(
'下载封面失败,跳过封面写入:',
coverError instanceof Error ? coverError.message : coverError
)
}
}
// 执行 ffmpeg 命令
await new Promise<void>((resolve, reject) => {
command
.on('start', () => {
console.log('执行 ffmpeg 命令')
})
.on('progress', (progress) => {
if (progress.percent) {
console.log('处理进度:', Math.round(progress.percent) + '%')
}
})
.on('end', () => {
console.log('ffmpeg 处理完成')
resolve()
})
.on('error', (err, _, stderr) => {
console.error('ffmpeg 错误:', err.message)
if (stderr) {
console.error('ffmpeg stderr:', stderr)
}
reject(new Error(`ffmpeg 处理失败: ${err.message}`))
})
.run()
})
// 检查临时文件是否创建成功
if (!fs.existsSync(tempOutputPath)) {
throw new Error('ffmpeg 未能创建输出文件')
}
// 替换原文件
await fsPromise.rename(tempOutputPath, filePath)
tempOutputPath = null // 标记已处理,避免重复清理
console.log('使用 fluent-ffmpeg 写入 FLAC 标签成功:', filePath)
} catch (error) {
console.error('使用 fluent-ffmpeg 写入 FLAC 标签失败:', error)
throw error
} finally {
// 清理所有临时文件
const filesToClean = [tempOutputPath, tempCoverPath].filter(Boolean) as string[]
for (const tempFile of filesToClean) {
try {
if (fs.existsSync(tempFile)) {
await fsPromise.unlink(tempFile)
console.log('已清理临时文件:', tempFile)
}
} catch (cleanupError) {
console.warn(
'清理临时文件失败:',
tempFile,
cleanupError instanceof Error ? cleanupError.message : cleanupError
)
}
}
}
}
// MP4/M4A 格式标签写入
async function writeMP4Tags(filePath: string, _songInfo: any, _tagWriteOptions: any) {
console.log('MP4/M4A 格式标签写入暂未实现:', filePath)
// 可以使用 ffmpeg 或其他工具实现
}
// 获取自定义下载目录
const getDownloadDirectory = (): string => {
// 使用配置管理服务获取下载目录
@@ -492,13 +257,71 @@ export default async function download(
delete fileLock[songPath]
}
// 写入标签信息
// 写入标签信息(使用 node-taglib-sharp
if (tagWriteOptions && fs.existsSync(songPath)) {
try {
await writeAudioTags(songPath, songInfo, tagWriteOptions)
const baseName = path.basename(songPath, path.extname(songPath))
const dirName = path.dirname(songPath)
let coverExt = '.jpg'
let coverPath = path.join(dirName, `${baseName}${coverExt}`)
let coverDownloaded = false
// 下载封面仅当启用且有URL
if (tagWriteOptions.cover && songInfo?.img) {
try {
const coverRes = await axios.get(songInfo.img, {
responseType: 'arraybuffer',
timeout: 10000
})
const ct =
(coverRes.headers && (coverRes.headers['content-type'] as string | undefined)) ||
undefined
coverExt = resolveCoverExt(songInfo.img, ct)
coverPath = path.join(dirName, `${baseName}${coverExt}`)
await fsPromise.writeFile(coverPath, Buffer.from(coverRes.data))
coverDownloaded = true
} catch (e) {
console.warn('下载封面失败:', e instanceof Error ? e.message : e)
}
}
// 读取歌曲文件并设置标签
const songFile = File.createFromPath(songPath)
// 使用默认 ID3v2.3
Id3v2Settings.forceDefaultVersion = true
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
// 写入歌词(转换为标准 LRC
if (tagWriteOptions.lyrics && songInfo?.lrc) {
const convertedLrc = convertLrcFormat(songInfo.lrc)
songFile.tag.lyrics = convertedLrc
}
// 写入封面
if (tagWriteOptions.cover && coverDownloaded) {
const songCover = Picture.fromPath(coverPath)
songFile.tag.pictures = [songCover]
}
// 保存并释放
songFile.save()
songFile.dispose()
// 删除临时封面
if (coverDownloaded) {
try {
await fsPromise.unlink(coverPath)
} catch {}
}
} catch (error) {
console.warn('写入音频标签失败:', error)
throw ffmpegStatic
console.warn('写入音乐元信息失败:', error)
}
}
return {

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate } from '../index'
import { decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
limit: 30,
@@ -9,87 +10,72 @@ export default {
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = httpFetch(
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(
str
)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`
)
return searchRequest.promise.then(({ body }) => body)
},
filterData(rawData) {
const types = []
const _types = {}
if (rawData.FileSize !== 0) {
const size = sizeFormate(rawData.FileSize)
types.push({ type: '128k', size, hash: rawData.FileHash })
_types['128k'] = {
size,
hash: rawData.FileHash
}
}
if (rawData.HQFileSize !== 0) {
const size = sizeFormate(rawData.HQFileSize)
types.push({ type: '320k', size, hash: rawData.HQFileHash })
_types['320k'] = {
size,
hash: rawData.HQFileHash
}
}
if (rawData.SQFileSize !== 0) {
const size = sizeFormate(rawData.SQFileSize)
types.push({ type: 'flac', size, hash: rawData.SQFileHash })
_types.flac = {
size,
hash: rawData.SQFileHash
}
}
if (rawData.ResFileSize !== 0) {
const size = sizeFormate(rawData.ResFileSize)
types.push({ type: 'flac24bit', size, hash: rawData.ResFileHash })
_types.flac24bit = {
size,
hash: rawData.ResFileHash
}
}
return {
singer: decodeName(formatSingerName(rawData.Singers, 'name')),
name: decodeName(rawData.SongName),
albumName: decodeName(rawData.AlbumName),
albumId: rawData.AlbumID,
songmid: rawData.Audioid,
source: 'kg',
interval: formatPlayTime(rawData.Duration),
_interval: rawData.Duration,
img: null,
lrc: null,
otherSource: null,
hash: rawData.FileHash,
types,
_types,
typeUrl: {}
}
},
handleResult(rawData) {
const ids = new Set()
const list = []
async handleResult(rawData) {
let ids = new Set()
const items = []
rawData.forEach((item) => {
const key = item.Audioid + item.FileHash
if (ids.has(key)) return
ids.add(key)
list.push(this.filterData(item))
for (const childItem of item.Grp) {
const key = item.Audioid + item.FileHash
if (ids.has(key)) continue
if (!ids.has(key)) {
ids.add(key)
list.push(this.filterData(childItem))
items.push(item)
}
for (const childItem of item.Grp || []) {
const childKey = childItem.Audioid + childItem.FileHash
if (!ids.has(childKey)) {
ids.add(childKey)
items.push(childItem)
}
}
})
const hashList = items.map((item) => item.FileHash)
let qualityInfoMap = {}
try {
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return items.map((item) => {
const { types = [], _types = {} } = qualityInfoMap[item.FileHash] || {}
return {
singer: decodeName(formatSingerName(item.Singers, 'name')),
name: decodeName(item.SongName),
albumName: decodeName(item.AlbumName),
albumId: item.AlbumID,
songmid: item.Audioid,
source: 'kg',
interval: formatPlayTime(item.Duration),
_interval: item.Duration,
img: null,
lrc: null,
otherSource: null,
hash: item.FileHash,
types,
_types,
typeUrl: {},
}
})
return list
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then((result) => {
return this.musicSearch(str, page, limit).then(async (result) => {
if (!result || result.error_code !== 0) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.data.lists)
let list = await this.handleResult(result.data.lists)
if (list == null) return this.search(str, page, limit, retryNum)
@@ -102,8 +88,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'kg'
source: 'kg',
})
})
}
}
},
}

View File

@@ -0,0 +1,190 @@
import { httpFetch } from '../../request'
import { dnsLookup } from '../utils'
import { headers, timeout } from '../options'
import { sizeFormate, decodeName, formatPlayTime } from '../../index'
import { formatSingerName } from '../utils'
console.log(headers);
export const getBatchMusicQualityInfo = (hashList) => {
const resources = hashList.map((hash) => ({
id: 0,
type: 'audio',
hash,
}))
const requestObj = httpFetch(
`https://gateway.kugou.com/goodsmstore/v1/get_res_privilege?appid=1005&clientver=20049&clienttime=${Date.now()}&mid=NeZha`,
{
method: 'post',
timeout,
headers,
body: {
behavior: 'play',
clientver: '20049',
resource: resources,
area_code: '1',
quality: '128',
qualities: [
'128',
'320',
'flac',
'high',
'dolby',
'viper_atmos',
'viper_tape',
'viper_clear',
],
},
lookup: dnsLookup,
family: 4,
}
)
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.error_code != 0)
return Promise.reject(new Error('获取音质信息失败'))
body.data.forEach((songData, index) => {
const hash = hashList[index]
const types = []
const _types = {}
if (!songData || !songData.relate_goods) return
for (const quality_data of songData.relate_goods) {
if (quality_data.quality === '128') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '128k', size, hash: quality_data.hash })
_types['128k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === '320') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: '320k', size, hash: quality_data.hash })
_types['320k'] = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'flac') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'flac', size, hash: quality_data.hash })
_types.flac = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'high') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'hires', size, hash: quality_data.hash })
_types.hires = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_clear') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'master', size, hash: quality_data.hash })
_types.master = {
size,
hash: quality_data.hash,
}
}
if (quality_data.quality === 'viper_atmos') {
let size = sizeFormate(quality_data.info.filesize)
types.push({ type: 'atmos', size, hash: quality_data.hash })
_types.atmos = {
size,
hash: quality_data.hash,
}
}
}
qualityInfoMap[hash] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}
export const getHashFromItem = (item) => {
if (item.hash) return item.hash
if (item.FileHash) return item.FileHash
if (item.audio_info && item.audio_info.hash) return item.audio_info.hash
return null
}
export const filterData = async (rawList, options = {}) => {
let processedList = rawList
if (options.removeDuplicates) {
let ids = new Set()
processedList = rawList.filter((item) => {
if (!item) return false
const audioId = item.audio_info?.audio_id || item.audio_id
if (ids.has(audioId)) return false
ids.add(audioId)
return true
})
}
const hashList = processedList.map((item) => getHashFromItem(item)).filter((hash) => hash)
const qualityInfoRequest = getBatchMusicQualityInfo(hashList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return processedList.map((item) => {
const hash = getHashFromItem(item)
const { types = [], _types = {} } = qualityInfoMap[hash] || {}
if (item.audio_info) {
return {
name: decodeName(item.songname),
singer: decodeName(item.author_name),
albumName: decodeName(item.album_info?.album_name || item.remark),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: options.fix
? formatPlayTime(parseInt(item.audio_info.timelength) / 1000)
: formatPlayTime(parseInt(item.audio_info.timelength)),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {},
}
}
return {
name: decodeName(item.songname),
singer: decodeName(item.singername) || formatSingerName(item.authors, 'author_name'),
albumName: decodeName(item.album_name || item.remark),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: options.fix ? formatPlayTime(item.duration / 1000) : formatPlayTime(item.duration),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {},
}
})
}

View File

@@ -1,8 +1,8 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, dateFormat, formatPlayCount } from '../../index'
import './vendors/infSign.min'
import { signatureParams } from './util'
import { filterData } from './quality_detail'
const handleSignature = (id, page, limit) =>
new Promise((resolve, reject) => {
@@ -14,7 +14,7 @@ const handleSignature = (id, page, limit) =>
isCDN: !0,
callback(i) {
resolve(i.signature)
}
},
}
)
})
@@ -27,36 +27,36 @@ export default {
listDetailLimit: 10000,
currentTagInfo: {
id: undefined,
info: undefined
info: undefined,
},
sortList: [
{
name: '推荐',
id: '5'
id: '5',
},
{
name: '最热',
id: '6'
id: '6',
},
{
name: '最新',
id: '7'
id: '7',
},
{
name: '热藏',
id: '3'
id: '3',
},
{
name: '飙升',
id: '8'
}
id: '8',
},
],
cache: new Map(),
regExps: {
listData: /global\.data = (\[.+\]);/,
listInfo: /global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};/,
// https://www.kugou.com/yy/special/single/1067062.html
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
},
parseHtmlDesc(html) {
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
@@ -71,18 +71,17 @@ export default {
if (tryNum > 2) throw new Error('try max num')
const { body } = await httpFetch(this.getSongListDetailUrl(id)).promise
const listData = body.match(this.regExps.listData)
const listInfo = body.match(this.regExps.listInfo)
let listData = body.match(this.regExps.listData)
let listInfo = body.match(this.regExps.listInfo)
if (!listData) return this.getListDetailBySpecialId(id, page, ++tryNum)
const list = await this.getMusicInfos(JSON.parse(listData[1]))
// listData = this.filterData(JSON.parse(listData[1]))
let list = await this.getMusicInfos(JSON.parse(listData[1]))
let name
let pic
if (listInfo) {
name = listInfo[1]
pic = listInfo[2]
}
const desc = this.parseHtmlDesc(body)
let desc = this.parseHtmlDesc(body)
return {
list,
@@ -93,10 +92,10 @@ export default {
info: {
name,
img: pic,
desc
desc,
// author: body.result.info.userinfo.username,
// play_count: formatPlayCount(body.result.listen_num),
}
},
}
},
getInfoUrl(tagId) {
@@ -116,11 +115,11 @@ export default {
const result = []
if (rawData.status !== 1) return result
for (const key of Object.keys(rawData.data)) {
const tag = rawData.data[key]
let tag = rawData.data[key]
result.push({
id: tag.special_id,
name: tag.special_name,
source: 'kg'
source: 'kg',
})
}
return result
@@ -135,8 +134,8 @@ export default {
parent_name: tag.pname,
id: tag.id,
name: tag.name,
source: 'kg'
}))
source: 'kg',
})),
})
}
return result
@@ -159,7 +158,7 @@ export default {
{
method: 'post',
headers: {
'User-Agent': 'KuGou2012-8275-web_browser_event_handler'
'User-Agent': 'KuGou2012-8275-web_browser_event_handler',
},
body: {
appid: 1001,
@@ -170,8 +169,8 @@ export default {
platform: 'pc',
userid: '262643156',
return_min: 6,
return_max: 15
}
return_max: 15,
},
}
)
return this._requestObj_listRecommend.promise.then(({ body }) => {
@@ -190,7 +189,7 @@ export default {
total: item.songcount,
grade: item.grade,
desc: item.intro,
source: 'kg'
source: 'kg',
}))
},
@@ -219,7 +218,7 @@ export default {
},
createTask(hashs) {
const data = {
let data = {
area_code: '1',
show_privilege: 1,
show_album_info: '1',
@@ -230,16 +229,16 @@ export default {
dfid: '-',
clienttime: Date.now(),
key: 'OIlwieks28dk2k092lksi2UIkp',
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname'
fields: 'album_info,author_name,audio_info,ori_audio_name,base,songname',
}
let list = hashs
const tasks = []
let tasks = []
while (list.length) {
tasks.push(Object.assign({ data: list.slice(0, 100) }, data))
if (list.length < 100) break
list = list.slice(100)
}
const url = 'http://gateway.kugou.com/v2/album_audio/audio'
let url = 'http://gateway.kugou.com/v2/album_audio/audio'
return tasks.map((task) =>
this.createHttp(url, {
method: 'POST',
@@ -250,13 +249,13 @@ export default {
'KG-Fake': '0',
'KG-RF': '00869891',
'User-Agent': 'Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi',
'x-router': 'kmr.service.kugou.com'
}
'x-router': 'kmr.service.kugou.com',
},
}).then((data) => data.map((s) => s[0]))
)
},
async getMusicInfos(list) {
return this.filterData2(
return await this.filterData(
await Promise.all(
this.createTask(this.deDuplication(list).map((item) => ({ hash: item.hash })))
).then(([...datas]) => datas.flat())
@@ -269,7 +268,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -277,13 +276,13 @@ export default {
mid: '21511157a05844bd085308bc76ef3343',
clienttime: 640612895,
key: '36164c4015e704673c588ee202b9ecb8',
data: id
}
data: id,
},
})
// console.log(songInfo)
// type 1单曲2歌单3电台4酷狗码5别人的播放队列
let songList
const info = songInfo.info
let info = songInfo.info
switch (info.type) {
case 2:
if (!info.global_collection_id) return this.getListDetailBySpecialId(info.id)
@@ -299,7 +298,7 @@ export default {
headers: {
'KG-RC': 1,
'KG-THash': 'network_super_call.cpp:3676261689:379',
'User-Agent': ''
'User-Agent': '',
},
body: {
appid: 1001,
@@ -313,13 +312,13 @@ export default {
userid: info.userid,
collect_type: 0,
page: 1,
pagesize: info.count
}
}
pagesize: info.count,
},
},
})
// console.log(songList)
}
const list = await this.getMusicInfos(songList || songInfo.list)
let list = await this.getMusicInfos(songList || songInfo.list)
return {
list,
page: 1,
@@ -330,9 +329,9 @@ export default {
name: info.name,
img: (info.img_size && info.img_size.replace('{size}', 240)) || info.img,
// desc: body.result.info.list_desc,
author: info.username
author: info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
@@ -342,8 +341,8 @@ export default {
{
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
},
}
)
if (!songInfo.list) {
@@ -354,7 +353,7 @@ export default {
this.getUserListDetail5(chain)
)
}
const list = await this.getMusicInfos(songInfo.list)
let list = await this.getMusicInfos(songInfo.list)
// console.log(info, songInfo)
return {
list,
@@ -366,14 +365,14 @@ export default {
name: songInfo.info.name,
img: songInfo.info.img,
// desc: body.result.info.list_desc,
author: songInfo.info.username
author: songInfo.info.username,
// play_count: formatPlayCount(info.count),
}
},
}
},
deDuplication(datas) {
const ids = new Set()
let ids = new Set()
return datas.filter(({ hash }) => {
if (ids.has(hash)) return false
ids.add(hash)
@@ -388,29 +387,25 @@ export default {
data: [
{
id: gcid,
id_type: 2
}
]
}
const result = await this.createHttp(
`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`,
{
method: 'POST',
headers: {
'User-Agent':
'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/'
id_type: 2,
},
body
}
)
],
}
const result = await this.createHttp(`https://t.kugou.com/v1/songlist/batch_decode?${params}&signature=${signatureParams(params, 'android', JSON.stringify(body))}`, {
method: 'POST',
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36',
Referer: 'https://m.kugou.com/',
},
body,
})
return result.list[0].global_collection_id
},
async getUserListDetailByLink({ info }, link) {
const listInfo = info['0']
let listInfo = info['0']
let total = listInfo.count
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 90 ? 90 : total
@@ -423,8 +418,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
}
).then((data) => data.list.info)
)
@@ -442,13 +437,13 @@ export default {
name: listInfo.name,
img: listInfo.pic && listInfo.pic.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.list_create_username
author: listInfo.list_create_username,
// play_count: formatPlayCount(listInfo.count),
}
},
}
},
createGetListDetail2Task(id, total) {
const tasks = []
let tasks = []
let page = 0
while (total) {
const limit = total > 300 ? 300 : total
@@ -464,7 +459,10 @@ export default {
'&srcappid=2919&clientver=20000&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-'
tasks.push(
this.createHttp(
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(params, 'web')}`,
`https://mobiles.kugou.com/api/v5/special/song_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163263991',
@@ -472,8 +470,8 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163263991'
}
clienttime: '1586163263991',
},
}
).then((data) => data.info)
)
@@ -481,14 +479,17 @@ export default {
return Promise.all(tasks).then(([...datas]) => datas.flat())
},
async getUserListDetail2(global_collection_id) {
const id = global_collection_id
let id = global_collection_id
if (id.length > 1000) throw new Error('get list error')
const params =
'appid=1058&specialid=0&global_specialid=' +
id +
'&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519&mid=1586163242519&uuid=1586163242519&dfid=-'
const info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(params, 'web')}`,
let info = await this.createHttp(
`https://mobiles.kugou.com/api/v5/special/info_v2?${params}&signature=${signatureParams(
params,
'web'
)}`,
{
headers: {
mid: '1586163242519',
@@ -496,12 +497,12 @@ export default {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-',
clienttime: '1586163242519'
}
clienttime: '1586163242519',
},
}
)
const songInfo = await this.createGetListDetail2Task(id, info.songcount)
const list = await this.getMusicInfos(songInfo)
let list = await this.getMusicInfos(songInfo)
// console.log(info, songInfo, list)
return {
list,
@@ -514,8 +515,8 @@ export default {
img: info.imgurl && info.imgurl.replace('{size}', 240),
desc: info.intro,
author: info.nickname,
play_count: formatPlayCount(info.playcount)
}
play_count: formatPlayCount(info.playcount),
},
}
},
@@ -524,8 +525,8 @@ export default {
const { body } = await httpFetch(`https://m.kugou.com/share/?chain=${chain}&id=${chain}`, {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
}
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
},
}).promise
let result = body.match(/var\sphpParam\s=\s({.+?});/)
if (result) result = JSON.parse(result[1])
@@ -534,13 +535,13 @@ export default {
},
async getUserListDetailByPcChain(chain) {
const key = `${chain}_pc_list`
let key = `${chain}_pc_list`
if (this.cache.has(key)) return this.cache.get(key)
const { body } = await httpFetch(`http://www.kugou.com/share/${chain}.html`, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'
}
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
},
}).promise
let result = body.match(/var\sdataFromSmarty\s=\s(\[.+?\])/)
if (result) result = JSON.parse(result[1])
@@ -554,7 +555,7 @@ export default {
const limit = 100
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailById(songInfo.id, page, limit)
this.getUserListDetailById(songInfo.id, page, limit),
])
return {
list: list || [],
@@ -566,16 +567,16 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetail5(chain) {
const [listInfo, list] = await Promise.all([
this.getListInfoByChain(chain),
this.getUserListDetailByPcChain(chain)
this.getUserListDetailByPcChain(chain),
])
return {
list: list || [],
@@ -587,28 +588,28 @@ export default {
name: listInfo.specialname,
img: listInfo.imgurl && listInfo.imgurl.replace('{size}', 240),
// desc: body.result.info.list_desc,
author: listInfo.nickname
author: listInfo.nickname,
// play_count: formatPlayCount(info.count),
}
},
}
},
async getUserListDetailById(id, page, limit) {
const signature = await handleSignature(id, page, limit)
const info = await this.createHttp(
let info = await this.createHttp(
`https://pubsongscdn.kugou.com/v2/get_other_list_file?srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist&page=${page}&pagesize=${limit}&specialid=${id}&signature=${signature}`,
{
headers: {
Referer: 'https://m3ws.kugou.com/share/index.php',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
dfid: '-'
}
dfid: '-',
},
}
)
// console.log(info)
const result = await this.getMusicInfos(info.info)
let result = await this.getMusicInfos(info.info)
// console.log(info, songInfo)
return result
},
@@ -616,19 +617,15 @@ export default {
async getUserListDetail(link, page, retryNum = 0) {
if (retryNum > 3) return Promise.reject(new Error('link try max num'))
if (link.includes('#')) link = link.replace(/#.*$/, '')
if (link.includes('global_collection_id'))
return this.getUserListDetail2(
link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (link.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
}
}
if (link.includes('chain='))
return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (link.includes('.html')) {
if (link.includes('zlist.html')) {
link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -650,34 +647,27 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode,
body
body,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
if (location) {
// console.log(location)
if (location.includes('global_collection_id'))
return this.getUserListDetail2(
location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')
)
if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (location.includes('gcid_')) {
const gcid = link.match(/gcid_\w+/)?.[0]
let gcid = link.match(/gcid_\w+/)?.[0]
if (gcid) {
const global_collection_id = await this.decodeGcid(gcid)
if (global_collection_id) return this.getUserListDetail2(global_collection_id)
}
}
if (location.includes('chain='))
return this.getUserListDetail3(
location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'),
page
)
if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page)
if (location.includes('.html')) {
if (location.includes('zlist.html')) {
let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list')
@@ -698,7 +688,7 @@ export default {
// console.log('location', location)
return this.getUserListDetail(location, page, ++retryNum)
}
if (typeof body === 'string') {
if (typeof body == 'string') {
let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1]
if (!global_collection_id) {
let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1]
@@ -729,184 +719,9 @@ export default {
return this.getListDetailBySpecialId(id, page)
},
filterData(rawList) {
// console.log(rawList)
return rawList.map((item) => {
const types = []
const _types = {}
if (item.filesize !== 0) {
const size = sizeFormate(item.filesize)
types.push({ type: '128k', size, hash: item.hash })
_types['128k'] = {
size,
hash: item.hash
}
}
if (item.filesize_320 !== 0) {
const size = sizeFormate(item.filesize_320)
types.push({ type: '320k', size, hash: item.hash_320 })
_types['320k'] = {
size,
hash: item.hash_320
}
}
if (item.filesize_ape !== 0) {
const size = sizeFormate(item.filesize_ape)
types.push({ type: 'ape', size, hash: item.hash_ape })
_types.ape = {
size,
hash: item.hash_ape
}
}
if (item.filesize_flac !== 0) {
const size = sizeFormate(item.filesize_flac)
types.push({ type: 'flac', size, hash: item.hash_flac })
_types.flac = {
size,
hash: item.hash_flac
}
}
return {
singer: decodeName(item.singername),
name: decodeName(item.songname),
albumName: decodeName(item.album_name),
albumId: item.album_id,
songmid: item.audio_id,
source: 'kg',
interval: formatPlayTime(item.duration / 1000),
img: null,
lrc: null,
hash: item.hash,
types,
_types,
typeUrl: {}
}
})
},
// getSinger(singers) {
// let arr = []
// singers?.forEach(singer => {
// arr.push(singer.name)
// })
// return arr.join('、')
// },
// v9 API
// filterDatav9(rawList) {
// console.log(rawList)
// return rawList.map(item => {
// const types = []
// const _types = {}
// item.relate_goods.forEach(qualityObj => {
// if (qualityObj.level === 2) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '128k', size, hash: qualityObj.hash })
// _types['128k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 4) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '320k', size, hash: qualityObj.hash })
// _types['320k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 5) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac', size, hash: qualityObj.hash })
// _types.flac = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 6) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
// _types.flac24bit = {
// size,
// hash: qualityObj.hash,
// }
// }
// })
// const nameInfo = item.name.split(' - ')
// return {
// singer: this.getSinger(item.singerinfo),
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
// albumName: decodeName(item.albuminfo.name),
// albumId: item.albuminfo.id,
// songmid: item.audio_id,
// source: 'kg',
// interval: formatPlayTime(item.timelen / 1000),
// img: null,
// lrc: null,
// hash: item.hash,
// types,
// _types,
// typeUrl: {},
// }
// })
// },
// hash list filter
filterData2(rawList) {
// console.log(rawList)
const ids = new Set()
const list = []
rawList.forEach((item) => {
if (!item) return
if (ids.has(item.audio_info.audio_id)) return
ids.add(item.audio_info.audio_id)
const types = []
const _types = {}
if (item.audio_info.filesize !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize))
types.push({ type: '128k', size, hash: item.audio_info.hash })
_types['128k'] = {
size,
hash: item.audio_info.hash
}
}
if (item.audio_info.filesize_320 !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_320))
types.push({ type: '320k', size, hash: item.audio_info.hash_320 })
_types['320k'] = {
size,
hash: item.audio_info.hash_320
}
}
if (item.audio_info.filesize_flac !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_flac))
types.push({ type: 'flac', size, hash: item.audio_info.hash_flac })
_types.flac = {
size,
hash: item.audio_info.hash_flac
}
}
if (item.audio_info.filesize_high !== '0') {
const size = sizeFormate(parseInt(item.audio_info.filesize_high))
types.push({ type: 'flac24bit', size, hash: item.audio_info.hash_high })
_types.flac24bit = {
size,
hash: item.audio_info.hash_high
}
}
list.push({
singer: decodeName(item.author_name),
name: decodeName(item.songname),
albumName: decodeName(item.album_info.album_name),
albumId: item.album_info.album_id,
songmid: item.audio_info.audio_id,
source: 'kg',
interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000),
img: null,
lrc: null,
hash: item.audio_info.hash,
otherSource: null,
types,
_types,
typeUrl: {}
})
})
return list
async filterData(rawList) {
return await filterData(rawList, { removeDuplicates: true, fix: true })
},
// 获取列表信息
@@ -920,14 +735,14 @@ export default {
limit: body.data.params.pagesize,
page: body.data.params.p,
total: body.data.params.total,
source: 'kg'
source: 'kg',
}
})
},
// 获取列表数据
getList(sortId, tagId, page) {
const tasks = [this.getSongList(sortId, tagId, page)]
let tasks = [this.getSongList(sortId, tagId, page)]
tasks.push(
this.currentTagInfo.id === tagId
? Promise.resolve(this.currentTagInfo.info)
@@ -943,7 +758,7 @@ export default {
if (recommendList) list.unshift(...recommendList)
return {
list,
...info
...info,
}
})
},
@@ -958,13 +773,13 @@ export default {
return {
hotTag: this.filterInfoHotTag(body.data.hotTag),
tags: this.filterTagInfo(body.data.tagids),
source: 'kg'
source: 'kg',
}
})
},
getDetailPageUrl(id) {
if (typeof id === 'string') {
if (typeof id == 'string') {
if (/^https?:\/\//.test(id)) return id
id = id.replace('id_', '')
}
@@ -975,7 +790,9 @@ export default {
// http://msearchretry.kugou.com/api/v3/search/special?version=9209&keyword=%E5%91%A8%E6%9D%B0%E4%BC%A6&pagesize=20&filter=0&page=1&sver=2&with_res_tag=0
// return httpFetch(`http://ioscdn.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&plat=2&version=7910&correct=1&sver=5`)
return httpFetch(
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(
text
)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`
).promise.then(({ body }) => {
if (body.errcode != 0) throw new Error('filed')
// console.log(body.data.info)
@@ -991,17 +808,17 @@ export default {
grade: item.grade,
desc: item.intro,
total: item.songcount,
source: 'kg'
source: 'kg',
}
}),
limit,
total: body.data.total,
source: 'kg'
source: 'kg',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -1,13 +1,13 @@
// import '../../polyfill/array.find'
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
// import { debug } from '../../utils/env'
import { formatSinger } from './util'
export default {
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
},
limit: 30,
total: 0,
@@ -32,7 +32,7 @@ export default {
// console.log(rawData)
for (let i = 0; i < rawData.length; i++) {
const info = rawData[i]
const songId = info.MUSICRID.replace('MUSIC_', '')
let songId = info.MUSICRID.replace('MUSIC_', '')
// const format = (info.FORMATS || info.formats).split('|')
if (!info.N_MINFO) {
@@ -43,33 +43,39 @@ export default {
const types = []
const _types = {}
const infoArr = info.N_MINFO.split(';')
let infoArr = info.N_MINFO.split(';')
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
types.push({ type: 'hires', size: info[4] })
_types.hires = {
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -77,7 +83,7 @@ export default {
}
types.reverse()
const interval = parseInt(info.DURATION)
let interval = parseInt(info.DURATION)
result.push({
name: decodeName(info.SONGNAME),
@@ -95,7 +101,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
// console.log(result)
@@ -109,7 +115,7 @@ export default {
// console.log(result)
if (!result || (result.TOTAL !== '0' && result.SHOW === '0'))
return this.search(str, page, limit, ++retryNum)
const list = this.handleResult(result.abslist)
let list = this.handleResult(result.abslist)
if (list == null) return this.search(str, page, limit, ++retryNum)
@@ -122,8 +128,8 @@ export default {
allPage: this.allPage,
total: this.total,
limit,
source: 'kw'
source: 'kw',
})
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, decodeName } from '../index'
import { formatPlayTime, decodeName } from '../../index'
import { formatSinger, objStr2JSON } from './util'
import album from './album'
@@ -13,18 +13,18 @@ export default {
sortList: [
{
name: '最新',
id: 'new'
id: 'new',
},
{
name: '最热',
id: 'hot'
}
id: 'hot',
},
],
regExps: {
mInfo: /level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)/,
// http://www.kuwo.cn/playlist_detail/2886046289
// https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl:
'http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0&loginUid=0&loginSid=0&appUid=76039576',
@@ -43,7 +43,9 @@ export default {
},
getListDetailUrl(id, page) {
// http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=2858093057&pn=0&rn=100&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
return `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${
this.limit_song
}&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1`
// http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id=140&prod=pc
},
@@ -72,7 +74,7 @@ export default {
return rawList.map((item) => ({
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
source: 'kw',
}))
},
filterTagInfo(rawList) {
@@ -83,8 +85,8 @@ export default {
parent_name: type.name,
id: `${item.id}-${item.digest}`,
name: item.name,
source: 'kw'
}))
source: 'kw',
})),
}))
},
@@ -95,7 +97,7 @@ export default {
let id
let type
if (tagId) {
const arr = tagId.split('-')
let arr = tagId.split('-')
id = arr[0]
type = arr[1]
} else {
@@ -110,7 +112,7 @@ export default {
total: body.data.total,
page: body.data.pn,
limit: body.data.rn,
source: 'kw'
source: 'kw',
}
} else if (!body.length) {
return this.getList(sortId, tagId, page, ++tryNum)
@@ -120,7 +122,7 @@ export default {
total: 1000,
page,
limit: 1000,
source: 'kw'
source: 'kw',
}
})
},
@@ -145,7 +147,7 @@ export default {
img: item.img,
grade: item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
},
filterList2(rawData) {
@@ -164,7 +166,7 @@ export default {
img: item.img,
grade: item.favorcnt && item.favorcnt / 10,
desc: item.desc,
source: 'kw'
source: 'kw',
}))
)
})
@@ -188,8 +190,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -207,7 +209,9 @@ export default {
getListDetailDigest5Music(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj = httpFetch(
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${
this.limit_song
}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`
)
return requestObj.promise.then(({ body }) => {
// console.log(body)
@@ -223,8 +227,8 @@ export default {
img: body.pic,
desc: body.info,
author: body.uname,
play_count: this.formatPlayCount(body.playnum)
}
play_count: this.formatPlayCount(body.playnum),
},
}
})
},
@@ -235,33 +239,33 @@ export default {
filterBDListDetail(rawList) {
return rawList.map((item) => {
const types = []
const _types = {}
for (const info of item.audios) {
let types = []
let _types = {}
for (let info of item.audios) {
info.size = info.size?.toLocaleUpperCase()
switch (info.bitrate) {
case '4000':
types.push({ type: 'flac24bit', size: info.size })
_types.flac24bit = {
size: info.size
size: info.size,
}
break
case '2000':
types.push({ type: 'flac', size: info.size })
_types.flac = {
size: info.size
size: info.size,
}
break
case '320':
types.push({ type: '320k', size: info.size })
_types['320k'] = {
size: info.size
size: info.size,
}
break
case '128':
types.push({ type: '128k', size: info.size })
_types['128k'] = {
size: info.size
size: info.size,
}
break
}
@@ -282,7 +286,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -299,8 +303,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -311,7 +315,7 @@ export default {
img: infoData.data.pic,
desc: infoData.data.description,
author: infoData.data.creatorName,
play_count: infoData.data.playNum
play_count: infoData.data.playNum,
}
},
async getListDetailMusicListByBDUserPub(id) {
@@ -321,8 +325,8 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => ({ code: 0 }))
@@ -334,18 +338,20 @@ export default {
img: infoData.data.userInfo.headImg,
desc: '',
author: infoData.data.userInfo.nickname,
play_count: ''
play_count: '',
}
},
async getListDetailMusicListByBDList(id, source, page, tryNum = 0) {
const { body: listData } = await httpFetch(
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${this.limit_song}`,
`https://bd-api.kuwo.cn/api/service/playlist/${id}/musicList?reqId=${this.getReqId()}&source=${source}&pn=${page}&rn=${
this.limit_song
}`,
{
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
plat: 'h5'
}
plat: 'h5',
},
}
).promise.catch(() => {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
@@ -359,7 +365,7 @@ export default {
page,
limit: listData.data.pageSize,
total: listData.data.total,
source: 'kw'
source: 'kw',
}
},
async getListDetailMusicListByBD(id, page) {
@@ -383,7 +389,7 @@ export default {
img: '',
desc: '',
author: '',
play_count: ''
play_count: '',
}
// console.log(listData)
return listData
@@ -415,35 +421,53 @@ export default {
filterListDetail(rawData) {
// console.log(rawData)
return rawData.map((item) => {
const infoArr = item.N_MINFO.split(';')
const types = []
const _types = {}
let infoArr = item.N_MINFO.split(';')
let types = []
let _types = {}
for (let info of infoArr) {
info = info.match(this.regExps.mInfo)
if (info) {
switch (info[2]) {
case '20900':
types.push({ type: 'master', size: info[4] })
_types.master = {
size: info[4].toLocaleUpperCase(),
}
break
case '20501':
types.push({ type: 'atmos_plus', size: info[4] })
_types.atmos_plus = {
size: info[4].toLocaleUpperCase(),
}
break
case '20201':
types.push({ type: 'atmos', size: info[4] })
_types.atmos = {
size: info[4].toLocaleUpperCase(),
}
break
case '4000':
types.push({ type: 'flac24bit', size: info[4] })
types.push({ type: 'hires', size: info[4] })
_types.flac24bit = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '2000':
types.push({ type: 'flac', size: info[4] })
_types.flac = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '320':
types.push({ type: '320k', size: info[4] })
_types['320k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
case '128':
types.push({ type: '128k', size: info[4] })
_types['128k'] = {
size: info[4].toLocaleUpperCase()
size: info[4].toLocaleUpperCase(),
}
break
}
@@ -464,7 +488,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -472,13 +496,13 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'kw'
source: 'kw',
}))
},
getDetailPageUrl(id) {
if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
else if (/^digest-/.test(id)) {
const result = id.split('__')
let result = id.split('__')
id = result[1]
}
return `http://www.kuwo.cn/playlist_detail/${id}`
@@ -486,7 +510,9 @@ export default {
search(text, page, limit = 20) {
return httpFetch(
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${
page - 1
}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`
).promise.then(({ body }) => {
body = objStr2JSON(body)
// console.log(body)
@@ -501,17 +527,17 @@ export default {
// time: item.publish_time,
img: item.pic,
desc: decodeName(item.intro),
source: 'kw'
source: 'kw',
}
}),
limit,
total: parseInt(body.TOTAL),
source: 'kw'
source: 'kw',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../index'
import { sizeFormate, formatPlayTime } from '../../index'
import { toMD5, formatSingerName } from '../utils'
export const createSignature = (time, str) => {
@@ -17,100 +17,6 @@ export default {
page: 0,
allPage: 1,
// 旧版API
// musicSearch(str, page, limit) {
// const searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do?ua=Android_migu&version=5.0.1&text=${encodeURIComponent(str)}&pageNo=${page}&pageSize=${limit}&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A0%2C%22mvSong%22%3A0%2C%22songlist%22%3A0%2C%22bestShow%22%3A1%7D`, {
// searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// // searchRequest = httpFetch(`http://jadeite.migu.cn:7090/music_search/v2/search/searchAll?sid=4f87090d01c84984a11976b828e2b02c18946be88a6b4c47bcdc92fbd40762db&isCorrect=1&isCopyright=1&searchSwitch=%7B%22song%22%3A1%2C%22album%22%3A0%2C%22singer%22%3A0%2C%22tagSong%22%3A1%2C%22mvSong%22%3A0%2C%22bestShow%22%3A1%2C%22songlist%22%3A0%2C%22lyricSong%22%3A0%7D&pageSize=${limit}&text=${encodeURIComponent(str)}&pageNo=${page}&sort=0`, {
// headers: {
// // sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// // timestamp: 1578225871982,
// // appId: 'yyapp2',
// // mode: 'android',
// // ua: 'Android_migu',
// // version: '6.9.4',
// osVersion: 'android 7.0',
// 'User-Agent': 'okhttp/3.9.1',
// },
// })
// // searchRequest = httpFetch(`https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/search_all.do?isCopyright=1&isCorrect=1&pageNo=${page}&pageSize=${limit}&searchSwitch={%22song%22:1,%22album%22:0,%22singer%22:0,%22tagSong%22:0,%22mvSong%22:0,%22songlist%22:0,%22bestShow%22:0}&sort=0&text=${encodeURIComponent(str)}`)
// return searchRequest.promise.then(({ body }) => body)
// },
// handleResult(rawData) {
// // console.log(rawData)
// let ids = new Set()
// const list = []
// rawData.forEach(item => {
// if (ids.has(item.id)) return
// ids.add(item.id)
// const types = []
// const _types = {}
// item.newRateFormats && item.newRateFormats.forEach(type => {
// let size
// switch (type.formatType) {
// case 'PQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '128k', size })
// _types['128k'] = {
// size,
// }
// break
// case 'HQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: '320k', size })
// _types['320k'] = {
// size,
// }
// break
// case 'SQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac', size })
// _types.flac = {
// size,
// }
// break
// case 'ZQ':
// size = sizeFormate(type.size ?? type.androidSize)
// types.push({ type: 'flac24bit', size })
// _types.flac24bit = {
// size,
// }
// break
// }
// })
// const albumNInfo = item.albums && item.albums.length
// ? {
// id: item.albums[0].id,
// name: item.albums[0].name,
// }
// : {}
// list.push({
// singer: this.getSinger(item.singers),
// name: item.name,
// albumName: albumNInfo.name,
// albumId: albumNInfo.id,
// songmid: item.songId,
// copyrightId: item.copyrightId,
// source: 'mg',
// interval: null,
// img: item.imgItems && item.imgItems.length ? item.imgItems[0].img : null,
// lrc: null,
// lrcUrl: item.lyricUrl,
// mrcUrl: item.mrcurl,
// trcUrl: item.trcUrl,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// })
// return list
// },
musicSearch(str, page, limit) {
const time = Date.now().toString()
const signData = createSignature(time, str)
@@ -124,8 +30,8 @@ export default {
sign: signData.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
)
return searchRequest.promise.then(({ body }) => body)
@@ -150,28 +56,28 @@ export default {
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
break
case 'HQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
break
case 'SQ':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
break
case 'ZQ24':
size = sizeFormate(type.asize ?? type.isize)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
types.push({ type: 'hires', size })
_types.hires = {
size,
}
break
}
@@ -196,7 +102,7 @@ export default {
trcUrl: data.trcUrl,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
})
@@ -212,7 +118,7 @@ export default {
return Promise.reject(new Error(result ? result.info : '搜索失败'))
const songResultData = result.songResultData || { resultList: [], totalCount: 0 }
const list = this.filterData(songResultData.resultList)
let list = this.filterData(songResultData.resultList)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = parseInt(songResultData.totalCount)
@@ -224,8 +130,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'mg'
source: 'mg',
}
})
}
}
},
}

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { dateFormat, formatPlayCount } from '../index'
import { dateFormat, formatPlayCount } from '../../index'
import { filterMusicInfoList } from './musicInfo'
import { createSignature } from './musicSearch'
import { createHttpFetch } from './utils/index'
@@ -17,14 +17,14 @@ export default {
sortList: [
{
name: '推荐',
id: '15127315'
id: '15127315',
// id: '1',
},
{
name: '最新',
id: '15127272'
id: '15127272',
// id: '2',
}
},
],
regExps: {
list: /<li><div class="thumb">.+?<\/li>/g,
@@ -32,7 +32,7 @@ export default {
/.+data-original="(.+?)".*data-id="(\d+)".*<div class="song-list-name"><a\s.*?>(.+?)<\/a>.+<i class="iconfont cf-bofangliang"><\/i>(.+?)<\/div>/,
// https://music.migu.cn/v3/music/playlist/161044573?page=1
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/
listDetailLink: /^.+\/playlist\/(\d+)(?:\?.*|&.*$|#.*$|$)/,
},
tagsUrl: 'https://app.c.nf.migu.cn/MIGUM3.0/v1.0/template/musiclistplaza-taglist/release',
// tagsUrl: 'https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/indexTagPage.do?needAll=0',
@@ -43,6 +43,7 @@ export default {
// : `https://music.migu.cn/v3/music/playlist?sort=${sortId}&page=${page}&from=migu`
// }
// return `https://music.migu.cn/v3/music/playlist?tagId=${tagId}&page=${page}&from=migu`
// https://app.c.nf.migu.cn/pc/v1.0/template/musiclistplaza-listbytag/release?pageNumber=1&templateVersion=2&tagId=1003449727
if (tagId == null) {
// return `https://app.c.nf.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=1`
// return `https://c.musicapp.migu.cn/MIGUM2.0/v2.0/content/getMusicData.do?count=${this.limit_list}&start=${page}&templateVersion=5&type=${sortId}`
@@ -58,7 +59,7 @@ export default {
defaultHeaders: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'https://m.music.migu.cn/'
Referer: 'https://m.music.migu.cn/',
// language: 'Chinese',
// ua: 'Android_migu',
// mode: 'android',
@@ -74,7 +75,7 @@ export default {
} else if (/[?&:/]/.test(id)) id = id.replace(this.regExps.listDetailLink, '$1')
const requestObj_listDetail = httpFetch(this.getSongListDetailUrl(id, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
})
return requestObj_listDetail.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getListDetail(id, page, ++tryNum)
@@ -85,7 +86,7 @@ export default {
page,
limit: this.limit_song,
total: body.totalCount,
source: 'mg'
source: 'mg',
}
})
},
@@ -97,7 +98,7 @@ export default {
const requestObj_listDetailInfo = httpFetch(
`https://c.musicapp.migu.cn/MIGUM3.0/resource/playlist/v2.0?playlistId=${id}`,
{
headers: this.defaultHeaders
headers: this.defaultHeaders,
}
)
return requestObj_listDetailInfo.promise.then(({ body }) => {
@@ -109,7 +110,7 @@ export default {
img: body.data.imgItem.img,
desc: body.data.summary,
author: body.data.ownerName,
play_count: formatPlayCount(body.data.opNumItem.playNum)
play_count: formatPlayCount(body.data.opNumItem.playNum),
})
return cachedDetailInfo
})
@@ -122,12 +123,12 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
Referer: link
}
Referer: link,
},
})
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(body, location)
if (statusCode > 400) return this.getDetailUrl(link, page, ++retryNum)
@@ -153,7 +154,7 @@ export default {
return Promise.all([
this.getListDetailList(id, page, retryNum),
this.getListDetailInfo(id, retryNum)
this.getListDetailInfo(id, retryNum),
]).then(([listData, info]) => {
listData.info = info
return listData
@@ -165,7 +166,7 @@ export default {
if (this._requestObj_list) this._requestObj_list.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_list = httpFetch(this.getSongListUrl(sortId, tagId, page), {
headers: this.defaultHeaders
headers: this.defaultHeaders,
// headers: {
// sign: 'c3b7ae985e2206e97f1b2de8f88691e2',
// timestamp: 1578225871982,
@@ -205,7 +206,7 @@ export default {
total: parseInt(body.retMsg.countSize),
page,
limit: this.limit_list,
source: 'mg'
source: 'mg',
}
})
// return this._requestObj_list.promise.then(({ body }) => {
@@ -233,7 +234,7 @@ export default {
grade: item.grade,
total: item.contentCount,
desc: item.summary,
source: 'mg'
source: 'mg',
}))
},
@@ -254,7 +255,7 @@ export default {
hotTag: rawList[0].content.map(({ texts: [name, id] }) => ({
id,
name,
source: 'mg'
source: 'mg',
})),
tags: rawList.slice(1).map(({ header, content }) => ({
name: header.title,
@@ -263,10 +264,10 @@ export default {
// parent_name: objectInfo.columnTitle,
id,
name,
source: 'mg'
}))
source: 'mg',
})),
})),
source: 'mg'
source: 'mg',
}
// return {
// hotTag: rawList[0].objectInfo.contents.map(item => ({
@@ -313,7 +314,7 @@ export default {
name: item.name,
img: item.musicListPicUrl,
total: item.musicNum,
source: 'mg'
source: 'mg',
})
})
return list
@@ -331,8 +332,8 @@ export default {
sign: signResult.sign,
channel: '0146921',
'User-Agent':
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30'
}
'Mozilla/5.0 (Linux; U; Android 11.0.0; zh-cn; MI 11 Build/OPR1.170623.032) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
},
}
).then((body) => {
if (!body.songListResultData) throw new Error('get song list faild.')
@@ -342,10 +343,10 @@ export default {
list,
limit,
total: parseInt(body.songListResultData.totalCount),
source: 'mg'
source: 'mg',
}
})
}
},
}
// getList

View File

@@ -1,8 +1,7 @@
export const bHh = '624868746c'
export const headers = {
'User-Agent': 'lx-music request',
[bHh]: [bHh]
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
}
export const timeout = 15000

View File

@@ -1,5 +1,5 @@
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate } from '../index'
import { formatPlayTime, sizeFormate } from '../../index'
import { formatSingerName } from '../utils'
export default {
@@ -15,32 +15,56 @@ export default {
const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)'
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
ct: 11,
cv: '1003006',
v: '1003006',
ct: '11',
cv: '14090508',
v: '14090508',
tmeAppID: 'qqmusic',
phonetype: 'EBG-AN10',
deviceScore: '553.47',
devicelevel: '50',
newdevicelevel: '20',
rom: 'HuaWei/EMOTION/EmotionUI_14.2.0',
os_ver: '12',
phonetype: '0',
devicelevel: '31',
tmeAppID: 'qqmusiclight',
nettype: 'NETWORK_WIFI'
OpenUDID: '0',
OpenUDID2: '0',
QIMEI36: '0',
udid: '0',
chid: '0',
aid: '0',
oaid: '0',
taid: '0',
tid: '0',
wid: '0',
uid: '0',
sid: '0',
modeSwitch: '6',
teenMode: '0',
ui_mode: '2',
nettype: '1020',
v4ip: '',
},
req: {
module: 'music.search.SearchCgiService',
method: 'DoSearchForQQMusicLite',
method: 'DoSearchForQQMusicMobile',
param: {
query: str,
search_type: 0,
num_per_page: limit,
query: str,
page_num: page,
num_per_page: limit,
highlight: 0,
nqc_flag: 0,
grp: 1
}
}
}
multi_zhida: 0,
cat: 2,
grp: 1,
sin: 0,
sem: 0,
},
},
},
})
// searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`)
return searchRequest.promise.then(({ body }) => {
@@ -56,35 +80,56 @@ export default {
rawList.forEach((item) => {
if (!item.file?.media_mid) return
const types = []
const _types = {}
let types = []
let _types = {}
const file = item.file
if (file.size_128mp3 != 0) {
const size = sizeFormate(file.size_128mp3)
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
size,
}
}
if (file.size_320mp3 !== 0) {
const size = sizeFormate(file.size_320mp3)
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
size,
}
}
if (file.size_flac !== 0) {
const size = sizeFormate(file.size_flac)
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
size,
}
}
if (file.size_hires !== 0) {
const size = sizeFormate(file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = {
size,
}
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = {
size,
}
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = {
size,
}
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = {
size,
}
}
// types.reverse()
@@ -113,7 +158,7 @@ export default {
: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
types,
_types,
typeUrl: {}
typeUrl: {},
})
})
// console.log(list)
@@ -123,7 +168,7 @@ export default {
if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(({ body, meta }) => {
const list = this.handleResult(body.item_song)
let list = this.handleResult(body.item_song)
this.total = meta.estimate_sum
this.page = page
@@ -134,8 +179,8 @@ export default {
allPage: this.allPage,
limit,
total: this.total,
source: 'tx'
source: 'tx',
})
})
}
},
}

View File

@@ -0,0 +1,86 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getBatchMusicQualityInfo = (songList) => {
const songIds = songList.map((item) => item.id)
const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: {
ct: '19',
cv: '1859',
uin: '0',
},
req: {
module: 'music.trackInfo.UniformRuleCtrl',
method: 'CgiGetTrackInfo',
param: {
types: Array(songIds.length).fill(1),
ids: songIds,
ctx: 0,
},
},
},
})
const qualityInfoMap = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('获取音质信息失败'))
// Process each track from the response
body.req.data.tracks.forEach((track) => {
const file = track.file
const songId = track.id
const types = []
const _types = {}
if (file.size_128mp3 != 0) {
let size = sizeFormate(file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (file.size_320mp3 !== 0) {
let size = sizeFormate(file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (file.size_flac !== 0) {
let size = sizeFormate(file.size_flac)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (file.size_hires !== 0) {
let size = sizeFormate(file.size_hires)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (file.size_new[1] !== 0) {
let size = sizeFormate(file.size_new[1])
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
if (file.size_new[2] !== 0) {
let size = sizeFormate(file.size_new[2])
types.push({ type: 'atmos_plus', size })
_types.atmos_plus = { size }
}
if (file.size_new[0] !== 0) {
let size = sizeFormate(file.size_new[0])
types.push({ type: 'master', size })
_types.master = { size }
}
qualityInfoMap[songId] = { types, _types }
})
return qualityInfoMap
})
return requestObj
}

View File

@@ -1,6 +1,7 @@
import { httpFetch } from '../../request'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { decodeName, formatPlayTime, dateFormat, formatPlayCount } from '../../index'
import { formatSingerName } from '../utils'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
_requestObj_tags: null,
@@ -12,12 +13,12 @@ export default {
sortList: [
{
name: '最热',
id: 5
id: 5,
},
{
name: '最新',
id: 2
}
id: 2,
},
],
regExps: {
hotTagHtml: /class="c_bg_link js_tag_item" data-id="\w+">.+?<\/a>/g,
@@ -26,7 +27,7 @@ export default {
// https://y.qq.com/n/yqq/playlist/7217720898.html
// https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&appversion=9050006&id=7217720898&ADTAG=qfshare
listDetailLink: /\/playlist\/(\d+)/,
listDetailLink2: /id=(\d+)/
listDetailLink2: /id=(\d+)/,
},
tagsUrl:
'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D',
@@ -45,10 +46,10 @@ export default {
category_id: id,
size: this.limit_list,
page: page - 1,
use_page: 1
use_page: 1,
},
module: 'playlist.PlayListCategoryServer'
}
module: 'playlist.PlayListCategoryServer',
},
})
)}`
}
@@ -62,10 +63,10 @@ export default {
sin: this.limit_list * (page - 1),
size: this.limit_list,
order: sortId,
cur_page: page
cur_page: page,
},
module: 'playlist.PlayListPlazaServer'
}
module: 'playlist.PlayListPlazaServer',
},
})
)}`
},
@@ -95,17 +96,17 @@ export default {
})
},
filterInfoHotTag(html) {
const hotTag = html.match(this.regExps.hotTagHtml)
let hotTag = html.match(this.regExps.hotTagHtml)
const hotTags = []
if (!hotTag) return hotTags
hotTag.forEach((tagHtml) => {
const result = tagHtml.match(this.regExps.hotTag)
let result = tagHtml.match(this.regExps.hotTag)
if (!result) return
hotTags.push({
id: parseInt(result[1]),
name: result[2],
source: 'tx'
source: 'tx',
})
})
return hotTags
@@ -118,8 +119,8 @@ export default {
parent_name: type.group_name,
id: item.id,
name: item.name,
source: 'tx'
}))
source: 'tx',
})),
}))
},
@@ -130,7 +131,9 @@ export default {
this._requestObj_list = httpFetch(this.getListUrl(sortId, tagId, page))
// console.log(this.getListUrl(sortId, tagId, page))
return this._requestObj_list.promise.then(({ body }) => {
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
if (body.code !== this.successCode) {
return this.getList(sortId, tagId, page, ++tryNum)
}
return tagId
? this.filterList2(body.playlist.data, page)
: this.filterList(body.playlist.data, page)
@@ -149,12 +152,12 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_ids?.length,
desc: decodeName(item.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: data.total,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
filterList2({ content }, page) {
@@ -169,12 +172,12 @@ export default {
img: basic.cover.medium_url || basic.cover.default_url,
// grade: basic.favorcnt / 10,
desc: decodeName(basic.desc).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
})),
total: content.total_cnt,
page,
limit: this.limit_list,
source: 'tx'
source: 'tx',
}
},
@@ -184,7 +187,7 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(headers)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
@@ -202,7 +205,6 @@ export default {
if (!result) throw new Error('failed')
}
id = result[1]
// console.log(id)
}
return id
},
@@ -215,15 +217,16 @@ export default {
const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), {
headers: {
Origin: 'https://y.qq.com',
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`
}
Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`,
},
})
const { body } = await requestObj_listDetail.promise
console.log(body);
if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum)
const cdlist = body.cdlist[0]
return {
list: this.filterListDetail(cdlist.songlist),
list: await this.filterListDetail(cdlist.songlist),
page: 1,
limit: cdlist.songlist.length + 1,
total: cdlist.songlist.length,
@@ -233,44 +236,23 @@ export default {
img: cdlist.logo,
desc: decodeName(cdlist.desc).replace(/<br>/g, '\n'),
author: cdlist.nickname,
play_count: formatPlayCount(cdlist.visitnum)
}
play_count: formatPlayCount(cdlist.visitnum),
},
}
},
filterListDetail(rawList) {
// console.log(rawList)
async filterListDetail(rawList) {
const qualityInfoRequest = getBatchMusicQualityInfo(rawList)
let qualityInfoMap = {}
try {
qualityInfoMap = await qualityInfoRequest.promise
} catch (error) {
console.error('Failed to fetch quality info:', error)
}
return rawList.map((item) => {
const types = []
const _types = {}
if (item.file.size_128mp3 !== 0) {
const size = sizeFormate(item.file.size_128mp3)
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
if (item.file.size_320mp3 !== 0) {
const size = sizeFormate(item.file.size_320mp3)
types.push({ type: '320k', size })
_types['320k'] = {
size
}
}
if (item.file.size_flac !== 0) {
const size = sizeFormate(item.file.size_flac)
types.push({ type: 'flac', size })
_types.flac = {
size
}
}
if (item.file.size_hires !== 0) {
const size = sizeFormate(item.file.size_hires)
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
// types.reverse()
const { types = [], _types = {} } = qualityInfoMap[item.id] || {}
return {
singer: formatSingerName(item.singer, 'name'),
name: item.title,
@@ -292,7 +274,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
}
})
},
@@ -300,7 +282,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'tx'
source: 'tx',
}))
},
@@ -313,12 +295,16 @@ export default {
search(text, page, limit = 20, retryNum = 0) {
if (retryNum > 5) throw new Error('max retry')
return httpFetch(
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${page - 1}&num_per_page=${limit}&format=json&query=${encodeURIComponent(text)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
`http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist?page_no=${
page - 1
}&num_per_page=${limit}&format=json&query=${encodeURIComponent(
text
)}&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
Referer: 'http://y.qq.com/portal/search.html'
}
Referer: 'http://y.qq.com/portal/search.html',
},
}
).promise.then(({ body }) => {
if (body.code != 0) return this.search(text, page, limit, ++retryNum)
@@ -335,17 +321,17 @@ export default {
// grade: item.favorcnt / 10,
total: item.song_count,
desc: decodeName(decodeName(item.introduction)).replace(/<br>/g, '\n'),
source: 'tx'
source: 'tx',
}
}),
limit,
total: body.data.sum,
source: 'tx'
source: 'tx',
}
})
}
},
}
// getList
// getTags
// getListDetail
// getListDetail

View File

@@ -10,16 +10,13 @@ export const getHostIp = (hostname) => {
if (typeof result === 'object') return result
if (result === true) return
ipMap.set(hostname, true)
// console.log(hostname)
dns.lookup(
hostname,
{
// family: 4,
all: false
all: false,
},
(err, address, family) => {
if (err) return console.log(err)
// console.log(address, family)
ipMap.set(hostname, { address, family })
}
)
@@ -42,11 +39,11 @@ export const formatSingerName = (singers, nameKey = 'name', join = '、') => {
if (Array.isArray(singers)) {
const singer = []
singers.forEach((item) => {
const name = item[nameKey]
let name = item[nameKey]
if (!name) return
singer.push(name)
})
return decodeName(singer.join(join))
}
return decodeName(String(singers ?? ''))
}
}

View File

@@ -1,57 +1,23 @@
import { httpFetch } from '../../request'
import { weapi } from './utils/crypto'
import { formatPlayTime, sizeFormate } from '../index'
// https://github.com/Binaryify/NeteaseCloudMusicApi/blob/master/module/song_detail.js
import { formatPlayTime } from '../../index'
import { getBatchMusicQualityInfo } from './quality_detail'
export default {
getSinger(singers) {
const arr = []
let arr = []
singers?.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
},
filterList({ songs, privileges }) {
// console.log(songs, privileges)
async filterList({ songs, privileges }) {
const list = []
const idList = songs.map((item) => item.id)
const qualityInfoMap = await getBatchMusicQualityInfo(idList)
songs.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
const { types, _types } = qualityInfoMap[item.id] || { types: [], _types: {} }
if (item.pc) {
list.push({
@@ -67,7 +33,7 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
} else {
list.push({
@@ -83,11 +49,10 @@ export default {
otherSource: null,
types,
_types,
typeUrl: {}
typeUrl: {},
})
}
})
// console.log(list)
return list
},
async getList(ids = [], retryNum = 0) {
@@ -98,16 +63,15 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com'
origin: 'https://music.163.com',
},
form: weapi({
c: '[' + ids.map((id) => '{"id":' + id + '}').join(',') + ']',
ids: '[' + ids.join(',') + ']'
})
ids: '[' + ids.join(',') + ']',
}),
})
const { body, statusCode } = await requestObj.promise
if (statusCode != 200 || body.code !== 200) throw new Error('获取歌曲详情失败')
// console.log(body)
return { source: 'wy', list: this.filterList(body) }
}
}
return { source: 'wy', list: await this.filterList(body) }
},
}

View File

@@ -1,7 +1,5 @@
// import { httpFetch } from '../../request'
// import { weapi } from './utils/crypto'
import { sizeFormate, formatPlayTime } from '../index'
// import musicDetailApi from './musicDetail'
import { httpFetch } from '../../request'
import { sizeFormate, formatPlayTime } from '../../index'
import { eapiRequest } from './utils/index'
export default {
@@ -9,101 +7,129 @@ export default {
total: 0,
page: 0,
allPage: 1,
musicSearch(str, page, limit) {
const searchRequest = eapiRequest('/api/cloudsearch/pc', {
s: str,
type: 1, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
})
return searchRequest.promise.then(({ body }) => body)
},
getSinger(singers) {
const arr = []
singers.forEach((singer) => {
arr.push(singer.name)
})
return arr.join('、')
return singers.map((singer) => singer.name).join('、')
},
handleResult(rawList) {
// console.log(rawList)
if (!rawList) return []
return rawList.map((item) => {
const types = []
const _types = {}
let size
if (item.privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
return Promise.all(
rawList.map(async (item) => {
const types = []
const _types = {}
let size
try {
const requestObj = httpFetch(
`https://music.163.com/api/song/music/detail/get?songId=${item.id}`,
{
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
}
)
const { body, statusCode } = await requestObj.promise
if (statusCode !== 200 || !body || body.code !== 200) {
throw new Error('Failed to get song quality information')
}
if (body.data.jm && body.data.jm.size) {
size = sizeFormate(body.data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (body.data.db && body.data.db.size) {
size = sizeFormate(body.data.db.size)
types.push({ type: 'dolby', size })
_types.dolby = { size }
}
if (body.data.hr && body.data.hr.size) {
size = sizeFormate(body.data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (body.data.sq && body.data.sq.size) {
size = sizeFormate(body.data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (body.data.h && body.data.h.size) {
size = sizeFormate(body.data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (body.data.m && body.data.m.size) {
size = sizeFormate(body.data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (body.data.l && body.data.l.size) {
size = sizeFormate(body.data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {},
}
} catch (error) {
console.error(error.message)
return null
}
}
switch (item.privilege.maxbr) {
case 999000:
size = item.sq ? sizeFormate(item.sq.size) : null
types.push({ type: 'flac', size })
_types.flac = {
size
}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
return {
singer: this.getSinger(item.ar),
name: item.name,
albumName: item.al.name,
albumId: item.al.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al.picUrl,
lrc: null,
types,
_types,
typeUrl: {}
}
})
})
)
},
search(str, page = 1, limit, retryNum = 0) {
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
if (limit == null) limit = this.limit
return this.musicSearch(str, page, limit).then((result) => {
// console.log(result)
if (!result || result.code !== 200) return this.search(str, page, limit, retryNum)
const list = this.handleResult(result.result.songs || [])
// console.log(list)
return this.handleResult(result.result.songs || []).then((list) => {
if (!list || list.length === 0) return this.search(str, page, limit, retryNum)
if (list == null) return this.search(str, page, limit, retryNum)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
this.total = result.result.songCount || 0
this.page = page
this.allPage = Math.ceil(this.total / this.limit)
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy'
}
// return result.data
return {
list,
allPage: this.allPage,
limit: this.limit,
total: this.total,
source: 'wy',
}
})
})
}
}
},
}

View File

@@ -0,0 +1,91 @@
import { httpFetch } from '../../request'
import { sizeFormate } from '../../index'
export const getMusicQualityInfo = (id) => {
const requestObj = httpFetch(`https://music.163.com/api/song/music/detail/get?songId=${id}`, {
method: 'get',
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
origin: 'https://music.163.com',
},
})
const types = []
const _types = {}
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 && body.code != 200) return Promise.reject(new Error('获取音质信息失败' + id))
const data = body.data
types.length = 0
Object.keys(_types).forEach((key) => delete _types[key])
if (data.l != null && data.l.size != null) {
let size = sizeFormate(data.l.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
} else if (data.m != null && data.m.size != null) {
let size = sizeFormate(data.m.size)
types.push({ type: '128k', size })
_types['128k'] = { size }
}
if (data.h != null && data.h.size != null) {
let size = sizeFormate(data.h.size)
types.push({ type: '320k', size })
_types['320k'] = { size }
}
if (data.sq != null && data.sq.size != null) {
let size = sizeFormate(data.sq.size)
types.push({ type: 'flac', size })
_types.flac = { size }
}
if (data.hr != null && data.hr.size != null) {
let size = sizeFormate(data.hr.size)
types.push({ type: 'hires', size })
_types.hires = { size }
}
if (data.jm != null && data.jm.size != null) {
let size = sizeFormate(data.jm.size)
types.push({ type: 'master', size })
_types.master = { size }
}
if (data.je != null && data.je.size != null) {
let size = sizeFormate(data.je.size)
types.push({ type: 'atmos', size })
_types.atmos = { size }
}
return { types: [...types], _types: { ..._types } }
})
return { requestObj, types, _types }
}
export const getBatchMusicQualityInfo = async (idList) => {
const ids = idList.filter((id) => id)
const qualityPromises = ids.map((id) => {
const result = getMusicQualityInfo(id)
return result.requestObj.promise.catch((err) => {
console.error(`获取歌曲 ${id} 音质信息失败:`, err)
return { types: [], _types: {} }
})
})
const qualityResults = await Promise.all(qualityPromises)
const qualityInfoMap = {}
ids.forEach((id, index) => {
qualityInfoMap[id] = qualityResults[index] || { types: [], _types: {} }
})
return qualityInfoMap
}

View File

@@ -1,9 +1,9 @@
import { weapi, linuxapi } from './utils/crypto'
import { httpFetch } from '../../request'
import { formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index'
import { /* formatPlayTime, */ dateFormat, formatPlayCount } from '../../index'
import musicDetailApi from './musicDetail'
import { eapiRequest } from './utils/index'
import { formatSingerName } from '../utils'
// import { formatSingerName } from '../utils'
export default {
_requestObj_tags: null,
@@ -16,16 +16,12 @@ export default {
sortList: [
{
name: '最热',
id: 'hot'
}
// {
// name: '最新',
// id: 'new',
// },
id: 'hot',
},
],
regExps: {
listDetailLink: /^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)/,
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/
listDetailLink2: /^.+\/playlist\/(\d+)\/\d+\/.+$/,
},
async handleParseId(link, retryNum = 0) {
@@ -34,9 +30,8 @@ export default {
const requestObj_listDetailLink = httpFetch(link)
const {
headers: { location },
statusCode
statusCode,
} = await requestObj_listDetailLink.promise
// console.log(statusCode)
if (statusCode > 400) return this.handleParseId(link, ++retryNum)
const url = location == null ? link : location
return this.regExps.listDetailLink.test(url)
@@ -59,13 +54,11 @@ export default {
} else {
id = await this.handleParseId(id)
}
// console.log(id)
}
return { id, cookie }
},
async getListDetail(rawId, page, tryNum = 0) {
// 获取歌曲列表内的音乐
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (tryNum > 1000) return Promise.reject(new Error('try max num'))
const { id, cookie } = await this.getListId(rawId)
if (cookie) this.cookie = cookie
@@ -75,7 +68,7 @@ export default {
headers: {
'User-Agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
Cookie: this.cookie
Cookie: this.cookie,
},
form: linuxapi({
method: 'POST',
@@ -83,36 +76,30 @@ export default {
params: {
id,
n: this.limit_song,
s: 8
}
})
s: 8,
},
}),
})
const { statusCode, body } = await requestObj_listDetail.promise
if (statusCode !== 200 || body.code !== this.successCode)
return this.getListDetail(id, page, ++tryNum)
const limit = 1000
const rangeStart = (page - 1) * limit
// console.log(body)
let limit = 50
let rangeStart = (page - 1) * limit
let list
if (body.playlist.trackIds.length == body.privileges.length) {
list = this.filterListDetail(body)
} else {
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
try {
list = (
await musicDetailApi.getList(
body.playlist.trackIds.slice(rangeStart, limit * page).map((trackId) => trackId.id)
)
).list
} catch (err) {
console.log(err)
if (err.message == 'try max num') {
throw err
} else {
return this.getListDetail(id, page, ++tryNum)
}
}
// console.log(list)
return {
list,
page,
@@ -124,119 +111,79 @@ export default {
name: body.playlist.name,
img: body.playlist.coverImgUrl,
desc: body.playlist.description,
author: body.playlist.creator.nickname
}
author: body.playlist.creator.nickname,
},
}
},
filterListDetail({ playlist: { tracks }, privileges }) {
// console.log(tracks, privileges)
const list = []
tracks.forEach((item, index) => {
const types = []
const _types = {}
let size
let privilege = privileges[index]
if (privilege.id !== item.id) privilege = privileges.find((p) => p.id === item.id)
if (!privilege) return
if (privilege.maxBrLevel == 'hires') {
size = item.hr ? sizeFormate(item.hr.size) : null
types.push({ type: 'flac24bit', size })
_types.flac24bit = {
size
}
}
switch (privilege.maxbr) {
case 999000:
size = null
types.push({ type: 'flac', size })
_types.flac = {
size
}
// filterListDetail({ playlist: { tracks } }) {
// const list = []
// tracks.forEach((item) => {
// const types = []
// const _types = {}
case 320000:
size = item.h ? sizeFormate(item.h.size) : null
types.push({ type: '320k', size })
_types['320k'] = {
size
}
// if (item.pc) {
// list.push({
// singer: item.pc.ar ?? '',
// name: item.pc.sn ?? '',
// albumName: item.pc.alb ?? '',
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl ?? '',
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// } else {
// list.push({
// singer: formatSingerName(item.ar, 'name'),
// name: item.name ?? '',
// albumName: item.al?.name,
// albumId: item.al?.id,
// source: 'wy',
// interval: formatPlayTime(item.dt / 1000),
// songmid: item.id,
// img: item.al?.picUrl,
// lrc: null,
// otherSource: null,
// types,
// _types,
// typeUrl: {},
// })
// }
// })
// return list
// },
case 192000:
case 128000:
size = item.l ? sizeFormate(item.l.size) : null
types.push({ type: '128k', size })
_types['128k'] = {
size
}
}
types.reverse()
if (item.pc) {
list.push({
singer: item.pc.ar ?? '',
name: item.pc.sn ?? '',
albumName: item.pc.alb ?? '',
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl ?? '',
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
} else {
list.push({
singer: formatSingerName(item.ar, 'name'),
name: item.name ?? '',
albumName: item.al?.name,
albumId: item.al?.id,
source: 'wy',
interval: formatPlayTime(item.dt / 1000),
songmid: item.id,
img: item.al?.picUrl,
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {}
})
}
})
return list
},
// 获取列表数据
getList(sortId, tagId, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num'))
if (this._requestObj_list) this._requestObj_list.cancelHttp()
this._requestObj_list = httpFetch('https://music.163.com/weapi/playlist/list', {
method: 'post',
form: weapi({
cat: tagId || '全部', // 全部,华语,欧美,日语,韩语,粤语,小语种,流行,摇滚,民谣,电子,舞曲,说唱,轻音乐,爵士,乡村,R&B/Soul,古典,民族,英伦,金属,朋克,蓝调,雷鬼,世界音乐,拉丁,另类/独立,New Age,古风,后摇,Bossa Nova,清晨,夜晚,学习,工作,午休,下午茶,地铁,驾车,运动,旅行,散步,酒吧,怀旧,清新,浪漫,性感,伤感,治愈,放松,孤独,感动,兴奋,快乐,安静,思念,影视原声,ACG,儿童,校园,游戏,70后,80后,90后,网络歌曲,KTV,经典,翻唱,吉他,钢琴,器乐,榜单,00后
order: sortId, // hot,new
cat: tagId || '全部',
order: sortId,
limit: this.limit_list,
offset: this.limit_list * (page - 1),
total: true
})
total: true,
}),
})
return this._requestObj_list.promise.then(({ body }) => {
// console.log(body)
if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum)
return {
list: this.filterList(body.playlists),
total: parseInt(body.total),
page,
limit: this.limit_list,
source: 'wy'
source: 'wy',
}
})
},
filterList(rawData) {
// console.log(rawData)
return rawData.map((item) => ({
play_count: formatPlayCount(item.playCount),
id: String(item.id),
@@ -247,20 +194,18 @@ export default {
grade: item.grade,
total: item.trackCount,
desc: item.description,
source: 'wy'
source: 'wy',
}))
},
// 获取标签
getTag(tryNum = 0) {
if (this._requestObj_tags) this._requestObj_tags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_tags = httpFetch('https://music.163.com/weapi/playlist/catalogue', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_tags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterTagInfo(body)
})
@@ -274,7 +219,7 @@ export default {
parent_name: categories[item.category],
id: item.name,
name: item.name,
source: 'wy'
source: 'wy',
})
}
@@ -283,22 +228,20 @@ export default {
list.push({
name: categories[key],
list: subList[key],
source: 'wy'
source: 'wy',
})
}
return list
},
// 获取热门标签
getHotTag(tryNum = 0) {
if (this._requestObj_hotTags) this._requestObj_hotTags.cancelHttp()
if (tryNum > 2) return Promise.reject(new Error('try max num'))
this._requestObj_hotTags = httpFetch('https://music.163.com/weapi/playlist/hottags', {
method: 'post',
form: weapi({})
form: weapi({}),
})
return this._requestObj_hotTags.promise.then(({ body }) => {
// console.log(JSON.stringify(body))
if (body.code !== this.successCode) return this.getTag(++tryNum)
return this.filterHotTagInfo(body.tags)
})
@@ -307,7 +250,7 @@ export default {
return rawList.map((item) => ({
id: item.playlistTag.name,
name: item.playlistTag.name,
source: 'wy'
source: 'wy',
}))
},
@@ -315,7 +258,7 @@ export default {
return Promise.all([this.getTag(), this.getHotTag()]).then(([tags, hotTag]) => ({
tags,
hotTag,
source: 'wy'
source: 'wy',
}))
},
@@ -327,23 +270,18 @@ export default {
search(text, page, limit = 20) {
return eapiRequest('/api/cloudsearch/pc', {
s: text,
type: 1000, // 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频
type: 1000,
limit,
total: page == 1,
offset: limit * (page - 1)
offset: limit * (page - 1),
}).promise.then(({ body }) => {
if (body.code != this.successCode) throw new Error('filed')
// console.log(body)
return {
list: this.filterList(body.result.playlists),
limit,
total: body.result.playlistCount,
source: 'wy'
source: 'wy',
}
})
}
}
// getList
// getTags
// getListDetail
},
}

45
src/main/windows/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { BrowserWindow, BrowserWindowConstructorOptions, app } from 'electron'
import { windowsLog } from '../logger'
import { join } from 'path'
import icon from '../../../resources/logo.png?asset'
export const createWindow = (
options: BrowserWindowConstructorOptions = {}
): BrowserWindow | null => {
try {
const defaultOptions: BrowserWindowConstructorOptions = {
title: app.getName(),
width: 1280,
height: 720,
frame: false, // 创建后是否显示窗口
center: true, // 窗口居中
icon, // 窗口图标
autoHideMenuBar: true, // 隐藏菜单栏
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
// 禁用渲染器沙盒
sandbox: false,
// 禁用同源策略
webSecurity: false,
// 允许 HTTP
allowRunningInsecureContent: true,
// 禁用拼写检查
spellcheck: false,
// 启用 Node.js
nodeIntegration: true,
nodeIntegrationInWorker: true,
// 关闭上下文隔离,确保在窗口中注入 window.electron
contextIsolation: false,
backgroundThrottling: false
}
}
// 合并参数
options = Object.assign(defaultOptions, options)
// 创建窗口
const win = new BrowserWindow(options)
return win
} catch (error) {
windowsLog.error(error)
return null
}
}

View File

@@ -0,0 +1,95 @@
import { BrowserWindow, screen } from 'electron'
import { createWindow } from './index'
import { configManager } from '../services/ConfigManager'
import { join } from 'path'
import { lyricConfig } from '@common/types/config'
const lyricStore = {
get: () =>
configManager.get<lyricConfig>('lyric', {
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: screen.getPrimaryDisplay().workAreaSize.width / 2 - 400,
y: screen.getPrimaryDisplay().workAreaSize.height - 90,
width: 800,
height: 180
}),
set: (value: lyricConfig) => configManager.set<lyricConfig>('lyric', value)
}
class LyricWindow {
private win: BrowserWindow | null = null
constructor() {}
/**
* 主窗口事件
* @returns void
*/
private event(): void {
if (!this.win) return
// 歌词窗口缩放
this.win?.on('resized', () => {
const bounds = this.win?.getBounds()
if (bounds) {
const { width, height } = bounds
console.log('歌词窗口缩放:', width, height)
lyricStore.set({
...lyricStore.get(),
width,
height
})
}
})
this.win?.on('closed', () => {
this.win = null
})
}
/**
* 创建主窗口
* @returns BrowserWindow | null
*/
create(): BrowserWindow | null {
const { width, height, x, y } = lyricStore.get()
this.win = createWindow({
width: width || 800,
height: height || 180,
minWidth: 440,
minHeight: 120,
maxWidth: 1600,
maxHeight: 300,
show: false,
// 窗口位置
x,
y,
transparent: true,
backgroundColor: 'rgba(0, 0, 0, 0)',
alwaysOnTop: true,
resizable: true,
movable: true,
// 不在任务栏显示
skipTaskbar: true,
// 窗口不能最小化
minimizable: false,
// 窗口不能最大化
maximizable: false,
// 窗口不能进入全屏状态
fullscreenable: false
})
if (!this.win) return null
// 加载地址(开发环境用项目根目录,生产用打包后的相对路径)
this.win.loadFile(join(__dirname, '../main/src/web/lyric.html'))
// 窗口事件
this.event()
return this.win
}
/**
* 获取窗口
* @returns BrowserWindow | null
*/
getWin(): BrowserWindow | null {
return this.win
}
}
export default new LyricWindow()

View File

@@ -53,6 +53,8 @@ interface CustomAPI {
validateIntegrity: (hashId: string) => Promise<any>
repairData: (hashId: string) => Promise<any>
forceSave: (hashId: string) => Promise<any>
getFavoritesId: () => Promise<any>
setFavoritesId: (favoritesId: string) => Promise<any>
}
ai: {
@@ -124,6 +126,16 @@ interface CustomAPI {
pluginNotice: {
onPluginNotice: (listener: (...args: any[]) => void) => () => void
}
localMusic: {
selectDirs: () => Promise<string[]>
scan: (dirs: string[]) => Promise<any[]>
writeTags: (
filePath: string,
songInfo: any,
tagWriteOptions: any
) => Promise<{ success: boolean; message?: string }>
}
}
declare global {

View File

@@ -8,6 +8,11 @@ const api = {
console.log('preload: 发送 window-minimize 事件')
ipcRenderer.send('window-minimize')
},
// 阻止系统息屏
powerSaveBlocker: {
start: () => ipcRenderer.invoke('power-save-blocker:start'),
stop: () => ipcRenderer.invoke('power-save-blocker:stop')
},
maximize: () => {
console.log('preload: 发送 window-maximize 事件')
ipcRenderer.send('window-maximize')
@@ -111,7 +116,11 @@ const api = {
validateIntegrity: (hashId: string) =>
ipcRenderer.invoke('songlist:validate-integrity', hashId),
repairData: (hashId: string) => ipcRenderer.invoke('songlist:repair-data', hashId),
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId)
forceSave: (hashId: string) => ipcRenderer.invoke('songlist:force-save', hashId),
// 喜欢歌单ID持久化
getFavoritesId: () => ipcRenderer.invoke('songlist:get-favorites-id'),
setFavoritesId: (id: string) => ipcRenderer.invoke('songlist:set-favorites-id', id)
},
getUserConfig: () => ipcRenderer.invoke('get-user-config'),
@@ -181,6 +190,29 @@ const api = {
ipcRenderer.invoke('directory-settings:get-directory-size', dirPath)
},
// 本地音乐管理
localMusic: {
selectDirs: () => ipcRenderer.invoke('local-music:select-dirs'),
scan: async (dirs: string[]) => {
const res = await ipcRenderer.invoke('local-music:scan', dirs)
if (typeof res === 'string') {
try {
return JSON.parse(res)
} catch {
return []
}
}
return Array.isArray(res) ? res : []
},
writeTags: (filePath: string, songInfo: any, tagWriteOptions: any) =>
ipcRenderer.invoke('local-music:write-tags', { filePath, songInfo, tagWriteOptions }),
getDirs: () => ipcRenderer.invoke('local-music:get-dirs'),
setDirs: (dirs: string[]) => ipcRenderer.invoke('local-music:set-dirs', dirs),
getList: () => ipcRenderer.invoke('local-music:get-list'),
getUrlById: (id: string | number) => ipcRenderer.invoke('local-music:get-url', id),
clearIndex: () => ipcRenderer.invoke('local-music:clear-index')
},
// 插件通知相关
pluginNotice: {
onPluginNotice(callback: (data: string) => any) {
@@ -198,14 +230,14 @@ const api = {
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ipcRenderer })
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
window.electron = { ...electronAPI, ipcRenderer }
// @ts-ignore (define in dts)
window.api = api
}

View File

@@ -7,90 +7,72 @@
export {}
declare global {
const DialogPlugin: (typeof import('tdesign-vue-next'))['DialogPlugin']
const EffectScope: (typeof import('vue'))['EffectScope']
const computed: (typeof import('vue'))['computed']
const createApp: (typeof import('vue'))['createApp']
const customRef: (typeof import('vue'))['customRef']
const defineAsyncComponent: (typeof import('vue'))['defineAsyncComponent']
const defineComponent: (typeof import('vue'))['defineComponent']
const effectScope: (typeof import('vue'))['effectScope']
const getCurrentInstance: (typeof import('vue'))['getCurrentInstance']
const getCurrentScope: (typeof import('vue'))['getCurrentScope']
const getCurrentWatcher: (typeof import('vue'))['getCurrentWatcher']
const h: (typeof import('vue'))['h']
const inject: (typeof import('vue'))['inject']
const isProxy: (typeof import('vue'))['isProxy']
const isReactive: (typeof import('vue'))['isReactive']
const isReadonly: (typeof import('vue'))['isReadonly']
const isRef: (typeof import('vue'))['isRef']
const isShallow: (typeof import('vue'))['isShallow']
const markRaw: (typeof import('vue'))['markRaw']
const nextTick: (typeof import('vue'))['nextTick']
const onActivated: (typeof import('vue'))['onActivated']
const onBeforeMount: (typeof import('vue'))['onBeforeMount']
const onBeforeUnmount: (typeof import('vue'))['onBeforeUnmount']
const onBeforeUpdate: (typeof import('vue'))['onBeforeUpdate']
const onDeactivated: (typeof import('vue'))['onDeactivated']
const onErrorCaptured: (typeof import('vue'))['onErrorCaptured']
const onMounted: (typeof import('vue'))['onMounted']
const onRenderTracked: (typeof import('vue'))['onRenderTracked']
const onRenderTriggered: (typeof import('vue'))['onRenderTriggered']
const onScopeDispose: (typeof import('vue'))['onScopeDispose']
const onServerPrefetch: (typeof import('vue'))['onServerPrefetch']
const onUnmounted: (typeof import('vue'))['onUnmounted']
const onUpdated: (typeof import('vue'))['onUpdated']
const onWatcherCleanup: (typeof import('vue'))['onWatcherCleanup']
const provide: (typeof import('vue'))['provide']
const reactive: (typeof import('vue'))['reactive']
const readonly: (typeof import('vue'))['readonly']
const ref: (typeof import('vue'))['ref']
const resolveComponent: (typeof import('vue'))['resolveComponent']
const shallowReactive: (typeof import('vue'))['shallowReactive']
const shallowReadonly: (typeof import('vue'))['shallowReadonly']
const shallowRef: (typeof import('vue'))['shallowRef']
const toRaw: (typeof import('vue'))['toRaw']
const toRef: (typeof import('vue'))['toRef']
const toRefs: (typeof import('vue'))['toRefs']
const toValue: (typeof import('vue'))['toValue']
const triggerRef: (typeof import('vue'))['triggerRef']
const unref: (typeof import('vue'))['unref']
const useAttrs: (typeof import('vue'))['useAttrs']
const useCssModule: (typeof import('vue'))['useCssModule']
const useCssVars: (typeof import('vue'))['useCssVars']
const useDialog: (typeof import('naive-ui'))['useDialog']
const useId: (typeof import('vue'))['useId']
const useLoadingBar: (typeof import('naive-ui'))['useLoadingBar']
const useMessage: (typeof import('naive-ui'))['useMessage']
const useModel: (typeof import('vue'))['useModel']
const useNotification: (typeof import('naive-ui'))['useNotification']
const useSlots: (typeof import('vue'))['useSlots']
const useTemplateRef: (typeof import('vue'))['useTemplateRef']
const watch: (typeof import('vue'))['watch']
const watchEffect: (typeof import('vue'))['watchEffect']
const watchPostEffect: (typeof import('vue'))['watchPostEffect']
const watchSyncEffect: (typeof import('vue'))['watchSyncEffect']
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const isShallow: typeof import('vue')['isShallow']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type {
Component,
Slot,
Slots,
ComponentPublicInstance,
ComputedRef,
DirectiveBinding,
ExtractDefaultPropTypes,
ExtractPropTypes,
ExtractPublicPropTypes,
InjectionKey,
PropType,
Ref,
ShallowRef,
MaybeRef,
MaybeRefOrGetter,
VNode,
WritableComputedRef
} from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -12,6 +12,7 @@ declare module 'vue' {
AudioVisualizer: typeof import('./src/components/Play/AudioVisualizer.vue')['default']
ContextMenu: typeof import('./src/components/ContextMenu/ContextMenu.vue')['default']
Demo: typeof import('./src/components/ContextMenu/demo.vue')['default']
DesktopLyricStyle: typeof import('./src/components/Settings/DesktopLyricStyle.vue')['default']
DirectorySettings: typeof import('./src/components/Settings/DirectorySettings.vue')['default']
FloatBall: typeof import('./src/components/AI/FloatBall.vue')['default']
FullPlay: typeof import('./src/components/Play/FullPlay.vue')['default']
@@ -19,9 +20,15 @@ declare module 'vue' {
HomeLayout: typeof import('./src/components/layout/HomeLayout.vue')['default']
MusicCache: typeof import('./src/components/Settings/MusicCache.vue')['default']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']
NDataTable: typeof import('naive-ui')['NDataTable']
NDropdown: typeof import('naive-ui')['NDropdown']
NIcon: typeof import('naive-ui')['NIcon']
NModal: typeof import('naive-ui')['NModal']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpin: typeof import('naive-ui')['NSpin']
NText: typeof import('naive-ui')['NText']
PlaylistActions: typeof import('./src/components/Play/PlaylistActions.vue')['default']
PlaylistDrawer: typeof import('./src/components/Play/PlaylistDrawer.vue')['default']
@@ -40,6 +47,7 @@ declare module 'vue' {
TButton: typeof import('tdesign-vue-next')['Button']
TCard: typeof import('tdesign-vue-next')['Card']
TCheckbox: typeof import('tdesign-vue-next')['Checkbox']
TColorPicker: typeof import('tdesign-vue-next')['ColorPicker']
TContent: typeof import('tdesign-vue-next')['Content']
TDialog: typeof import('tdesign-vue-next')['Dialog']
TDivider: typeof import('tdesign-vue-next')['Divider']
@@ -50,6 +58,7 @@ declare module 'vue' {
TIcon: typeof import('tdesign-vue-next')['Icon']
TImage: typeof import('tdesign-vue-next')['Image']
TInput: typeof import('tdesign-vue-next')['Input']
TInputNumber: typeof import('tdesign-vue-next')['InputNumber']
TitleBarControls: typeof import('./src/components/TitleBarControls.vue')['default']
TLayout: typeof import('tdesign-vue-next')['Layout']
TLoading: typeof import('tdesign-vue-next')['Loading']

View File

@@ -31,6 +31,25 @@ onMounted(() => {
syncNaiveTheme()
window.addEventListener('theme-changed', () => syncNaiveTheme())
// 全局键盘/托盘播放控制安装(解耦出组件)
import('@renderer/utils/audio/globalControls')
.then((m) => m.installGlobalMusicControls())
.catch(() => {})
import('@renderer/utils/audio/globaPlayList').then((m) => m.initPlayback?.()).catch(() => {})
import('@renderer/utils/lyrics/desktopLyricBridge')
.then((m) => m.installDesktopLyricBridge())
.catch(() => {})
// 全局监听来自主进程的播放控制事件,确保路由切换也可响应
const forward = (name: string, val?: any) => {
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
}
window.electron?.ipcRenderer?.on?.('play', () => forward('play'))
window.electron?.ipcRenderer?.on?.('pause', () => forward('pause'))
window.electron?.ipcRenderer?.on?.('toggle', () => forward('toggle'))
window.electron?.ipcRenderer?.on?.('playPrev', () => forward('playPrev'))
window.electron?.ipcRenderer?.on?.('playNext', () => forward('playNext'))
// 应用启动后延迟3秒检查更新避免影响启动速度
setTimeout(() => {
checkForUpdates()

View File

@@ -0,0 +1,3 @@
<svg class="lyrics-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M192 240a112 112 0 0 1 111.616 102.784l0.384 9.216V832a16 16 0 0 0 12.352 15.552L320 848h66.816a48 48 0 0 1 6.528 95.552l-6.528 0.448H320a112 112 0 0 1-111.616-102.784L208 832V352a16 16 0 0 0-12.352-15.552L192 336H128a48 48 0 0 1-6.528-95.552L128 240h64z m640-157.568a112 112 0 0 1 111.616 102.848l0.384 9.152V832a112 112 0 0 1-102.784 111.616L832 944h-67.84a48 48 0 0 1-6.464-95.552l6.464-0.448H832a16 16 0 0 0 15.552-12.352L848 832V194.432a16 16 0 0 0-12.352-15.552L832 178.432H480a48 48 0 0 1-6.528-95.552l6.528-0.448H832z m-160 315.136c61.824 0 112 50.112 112 112v147.648a112 112 0 0 1-112 112h-128a112 112 0 0 1-112-112V509.568c0-61.888 50.176-112 112-112z m0 96h-128a16 16 0 0 0-16 16v147.648c0 8.832 7.168 16 16 16h128a16 16 0 0 0 16-16V509.568a16 16 0 0 0-16-16z m64-253.568a48 48 0 0 1 6.528 95.552l-6.528 0.448h-256a48 48 0 0 1-6.528-95.552L480 240h256zM256 82.432a48 48 0 0 1 6.528 95.616L256 178.432H128a48 48 0 0 1-6.528-95.552L128 82.432h128z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -234,6 +234,8 @@
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #fff7e6;
--song-list-quality-color: #fa8c16;
--song-list-source-bg: #f3feff;
--song-list-source-color: #00d4e3;
/* Search 页面专用变量 - 亮色主题 */
--search-bg: var(--theme-bg-tertiary);
@@ -302,8 +304,8 @@
/* TitleBarControls 组件专用变量 - 亮色主题 */
--titlebar-icon-color: #111827;
--titlebar-icon-hover: #111827;
--titlebar-btn-hover-bg: #f3f4f6;
--titlebar-close-hover-bg: #fee2e2;
--titlebar-btn-hover-bg: #00000041;
--titlebar-close-hover-bg: #ffa1a176;
--titlebar-close-hover-color: #dc2626;
/* Settings 页面专用变量 - 亮色主题 */
@@ -597,6 +599,8 @@
--song-list-btn-bg-hover: var(--td-brand-color-light);
--song-list-quality-bg: #3a2a1a;
--song-list-quality-color: #fa8c16;
--song-list-source-bg: #343939;
--song-list-source-color: #00eeff;
/* Search 页面专用变量 - 暗色主题 */
--search-bg: var(--theme-bg-tertiary);

View File

@@ -1,12 +1,14 @@
<template>
<div class="song-virtual-list">
<!-- 表头 -->
<div class="list-header">
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
<div v-if="showDuration" class="col-duration">时长</div>
<div class="list-header-container" style="background-color: var(--song-list-header-bg)">
<div class="list-header" :style="{ marginRight: hasScroll ? '10px' : '0' }">
<div v-if="showIndex" class="col-index">#</div>
<div class="col-title">标题</div>
<div v-if="showAlbum" class="col-album">专辑</div>
<div class="col-like">喜欢</div>
<div v-if="showDuration" class="col-duration">时长</div>
</div>
</div>
<!-- 虚拟滚动容器 -->
@@ -15,7 +17,7 @@
<div class="virtual-scroll-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(song, index) in visibleItems"
:key="song.id || song.songmid"
:key="`${song.source || ''}-${song.songmid}-${song.albumId || ''}-${index}`"
class="song-item"
@mouseenter="hoveredSong = song.id || song.songmid"
@mouseleave="hoveredSong = null"
@@ -44,6 +46,9 @@
<span v-if="song.types && song.types.length > 0" class="quality-tag">
{{ getQualityDisplayName(song.types[song.types.length - 1]) }}
</span>
<span v-if="song.source" class="source-tag">
{{ song.source }}
</span>
{{ song.singer }}
</div>
</div>
@@ -58,8 +63,17 @@
<!-- 喜欢按钮 -->
<div class="col-like">
<button class="action-btn like-btn" @click.stop>
<i class="icon-heart"></i>
<button
class="action-btn like-btn"
title="喜欢/取消喜欢"
@click.stop="onToggleLike(song)"
>
<HeartIcon
:fill-color="isLiked(song) ? ['#e5484d', '#e5484d'] : ''"
:stroke-color="isLiked(song) ? [] : [contrastTextColor, contrastTextColor]"
:stroke-width="isLiked(song) ? 0 : 2"
size="18"
/>
</button>
</div>
@@ -110,7 +124,8 @@ import {
PlayCircleIcon,
AddIcon,
FolderIcon,
DeleteIcon
DeleteIcon,
HeartIcon
} from 'tdesign-icons-vue-next'
import ContextMenu from '../ContextMenu/ContextMenu.vue'
import { createMenuItem, createSeparator, calculateMenuPosition } from '../ContextMenu/utils'
@@ -188,6 +203,13 @@ const contextMenuSong = ref<Song | null>(null)
// 歌单列表
const playlists = ref<SongList[]>([])
const hasScroll = computed(() => {
// 判断是否有滚动条
return !!(
scrollContainer.value && scrollContainer.value.scrollHeight > scrollContainer.value.clientHeight
)
})
// 计算总高度
const totalHeight = computed(() => props.songs.length * itemHeight)
@@ -290,8 +312,9 @@ const getQualityDisplayName = (quality: any) => {
// 处理滚动事件
const onScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
const target = event.target as HTMLElement | null
// 兼容程序触发的假事件target 可能为 null
scrollTop.value = target?.scrollTop ?? scrollContainer.value?.scrollTop ?? 0
emit('scroll', event)
}
@@ -405,6 +428,89 @@ const loadPlaylists = async () => {
}
}
// === 喜欢功能(列表内心形) ===
const favoritesId = ref<string | null>(null)
const likedSet = ref<Set<string | number>>(new Set())
const contrastTextColor = 'var(--song-list-btn-color)'
const loadFavorites = async () => {
try {
const favIdRes = await (window as any).api?.songList?.getFavoritesId?.()
const id: string | null = (favIdRes && favIdRes.data) || null
favoritesId.value = id
if (!id) {
likedSet.value = new Set()
return
}
const existsRes = await songListAPI.exists(id)
if (!existsRes.success || !existsRes.data) {
favoritesId.value = null
likedSet.value = new Set()
return
}
const songsRes = await songListAPI.getSongs(id)
if (songsRes.success && Array.isArray(songsRes.data)) {
likedSet.value = new Set(songsRes.data.map((s: any) => s.songmid))
}
} catch (e) {
console.error('加载“我的喜欢”失败:', e)
}
}
const isLiked = (song: Song) => likedSet.value.has(song.songmid)
const ensureFavoritesId = async (): Promise<string | null> => {
if (favoritesId.value) {
const existsRes = await songListAPI.exists(favoritesId.value)
if (existsRes.success && existsRes.data) return favoritesId.value
favoritesId.value = null
}
const searchRes = await songListAPI.search('我的喜欢', 'local')
if (searchRes.success && Array.isArray(searchRes.data)) {
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
if (exact?.id) {
favoritesId.value = exact.id
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
return favoritesId.value
}
}
const createRes = await songListAPI.create('我的喜欢', '', 'local')
if (!createRes.success || !createRes.data?.id) {
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
return null
}
favoritesId.value = createRes.data.id
await (window as any).api?.songList?.setFavoritesId?.(favoritesId.value)
return favoritesId.value
}
const onToggleLike = async (song: Song) => {
try {
const id = await ensureFavoritesId()
if (!id) return
if (isLiked(song)) {
const removeRes = await songListAPI.removeSong(id, song.songmid)
if (removeRes.success && removeRes.data) {
likedSet.value.delete(song.songmid)
// MessagePlugin.success('已取消喜欢')
} else {
MessagePlugin.error(removeRes.error || '取消喜欢失败')
}
} else {
const addRes = await songListAPI.addSongs(id, [toRaw(song) as any])
if (addRes.success) {
likedSet.value.add(song.songmid)
// MessagePlugin.success('已添加到“我的喜欢”')
} else {
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
}
}
} catch (e: any) {
console.error('切换喜欢失败:', e)
MessagePlugin.error(e?.message || '操作失败,请稍后重试')
}
}
// 添加歌曲到歌单
const handleAddToSongList = async (song: Song, playlist: SongList) => {
try {
@@ -420,7 +526,7 @@ const handleAddToSongList = async (song: Song, playlist: SongList) => {
}
}
onMounted(() => {
onMounted(async () => {
// 组件挂载后触发一次重新计算
nextTick(() => {
if (scrollContainer.value) {
@@ -431,10 +537,15 @@ onMounted(() => {
})
// 加载歌单列表
loadPlaylists()
await loadPlaylists()
// 预加载“我的喜欢”集合(确保方法存在于当前文件作用域)
await loadFavorites()
// 监听歌单变化事件
window.addEventListener('playlist-updated', loadPlaylists)
window.addEventListener('playlist-updated', async () => {
await loadPlaylists()
await loadFavorites()
})
})
onUnmounted(() => {
@@ -462,8 +573,8 @@ onUnmounted(() => {
.list-header {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
grid-template-columns: 50px 1fr 200px 60px 80px;
padding: 8px 10px;
background: var(--song-list-header-bg);
border-bottom: 1px solid var(--song-list-header-border);
font-size: 12px;
@@ -481,7 +592,7 @@ onUnmounted(() => {
}
.col-title {
padding-left: 20px;
padding-left: 10px;
display: flex;
align-items: center;
}
@@ -528,8 +639,8 @@ onUnmounted(() => {
.song-item {
display: grid;
grid-template-columns: 60px 1fr 200px 60px 80px;
padding: 8px 20px;
grid-template-columns: 50px 1fr 200px 60px 80px;
padding: 8px 10px;
border-bottom: 1px solid var(--song-list-item-border);
cursor: pointer;
transition: background-color 0.2s ease;
@@ -562,7 +673,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
width: 100%;
.track-number {
font-size: 14px;
@@ -660,6 +771,14 @@ onUnmounted(() => {
font-size: 10px;
line-height: 1;
}
.source-tag {
background: var(--song-list-source-bg);
color: var(--song-list-source-color);
padding: 1px 4px;
border-radius: 2px;
font-size: 10px;
line-height: 1;
}
}
}
}

View File

@@ -28,6 +28,8 @@ const emit = defineEmits<{
const canvasRef = ref<HTMLCanvasElement>()
const animationId = ref<number>()
const analyser = ref<AnalyserNode>()
// 节流渲染,目标 ~30fps
const lastFrameTime = ref(0)
const dataArray = ref<Uint8Array>()
const resizeObserver = ref<ResizeObserver>()
const componentId = ref<string>(`visualizer-${Date.now()}-${Math.random()}`)
@@ -75,93 +77,87 @@ const initAudioAnalyser = () => {
}
// 绘制可视化
const draw = () => {
const draw = (ts?: number) => {
if (!canvasRef.value || !analyser.value || !dataArray.value) return
// 帧率节流 ~30fps
const now = ts ?? performance.now()
if (now - lastFrameTime.value < 33) {
animationId.value = requestAnimationFrame(draw)
return
}
lastFrameTime.value = now
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
if (!ctx) {
animationId.value = requestAnimationFrame(draw)
return
}
// 获取频域数据或生成模拟数据
if (analyser.value && dataArray.value) {
// 有真实音频分析器,获取真实数据
analyser.value.getByteFrequencyData(dataArray.value as Uint8Array<ArrayBuffer>)
} else {
// 没有音频分析器,生成模拟数据
const time = Date.now() * 0.001
const time = now * 0.001
for (let i = 0; i < dataArray.value.length; i++) {
// 生成基于时间的模拟频谱数据
const frequency = i / dataArray.value.length
const amplitude = Math.sin(time * 2 + frequency * 10) * 0.5 + 0.5
const bass = Math.sin(time * 4) * 0.3 + 0.7 // 低频变化
const bass = Math.sin(time * 4) * 0.3 + 0.7
dataArray.value[i] = Math.floor(amplitude * bass * 255 * (1 - frequency * 0.7))
}
}
// 计算低频音量 (80hz-120hz 范围)
// 假设采样率为 44100HzfftSize 为 256则每个频率 bin 约为 172Hz
// 80-120Hz 大约对应前 1-2 个 bin
const lowFreqStart = 0
const lowFreqEnd = Math.min(3, dataArray.value.length) // 取前几个低频 bin
// 计算低频音量(前 3 个 bin
let lowFreqSum = 0
for (let i = lowFreqStart; i < lowFreqEnd; i++) {
lowFreqSum += dataArray.value[i]
}
const lowFreqVolume = lowFreqSum / (lowFreqEnd - lowFreqStart) / 255
const lowBins = Math.min(3, dataArray.value.length)
for (let i = 0; i < lowBins; i++) lowFreqSum += dataArray.value[i]
emit('lowFreqUpdate', lowFreqSum / lowBins / 255)
// 发送低频音量给父组件
emit('lowFreqUpdate', lowFreqVolume)
// 完全清空画布
// 清屏
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 如果有背景色,再填充背景
// 背景
if (props.backgroundColor !== 'transparent') {
ctx.fillStyle = props.backgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
// 使用容器的实际尺寸进行计算,因为 ctx 已经缩放过了
// 计算尺寸
const container = canvas.parentElement
if (!container) return
if (!container) {
animationId.value = requestAnimationFrame(draw)
return
}
const containerRect = container.getBoundingClientRect()
const canvasWidth = containerRect.width
const canvasHeight = props.height
// 计算对称柱状参数
// 柱状参数
const halfBarCount = Math.floor(props.barCount / 2)
const barWidth = canvasWidth / 2 / halfBarCount
const maxBarHeight = canvasHeight * 0.9
const centerX = canvasWidth / 2
// 绘制左右对称的频谱柱状图
// 每帧仅创建一次渐变(自底向上),减少对象分配
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制对称频谱
for (let i = 0; i < halfBarCount; i++) {
// 增强低频响应,让可视化更敏感
let barHeight = (dataArray.value[i] / 255) * maxBarHeight
// 对数据进行增强处理,让变化更明显
barHeight = Math.pow(barHeight / maxBarHeight, 0.6) * maxBarHeight
const y = canvasHeight - barHeight
// 创建渐变色
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, y)
gradient.addColorStop(0, props.color)
gradient.addColorStop(1, props.color.replace(/[\d\.]+\)$/g, '0.3)'))
ctx.fillStyle = gradient
// 绘制左侧柱状图(从中心向左)
const leftX = centerX - (i + 1) * barWidth
ctx.fillRect(leftX, y, barWidth, barHeight)
// 绘制右侧柱状图(从中心向右)
const rightX = centerX + i * barWidth
ctx.fillRect(rightX, y, barWidth, barHeight)
}
// 继续动画
if (props.show && Audio.value.isPlay) {
animationId.value = requestAnimationFrame(draw)
}
@@ -286,6 +282,10 @@ onBeforeUnmount(() => {
analyser.value.disconnect()
analyser.value = undefined
}
// 通知管理器移除对该分析器的引用,防止 Map 持有导致 GC 不回收
try {
audioManager.removeAnalyser(componentId.value)
} catch {}
} catch (error) {
console.warn('清理音频资源时出错:', error)
}

View File

@@ -30,7 +30,7 @@ interface Props {
show?: boolean
coverImage?: string
songId?: string | null
songInfo: SongList | { songmid: number | null | string }
songInfo: SongList | { songmid: number | null | string; lrc: string | null }
mainColor: string
}
@@ -92,103 +92,208 @@ const state = reactive({
// 监听歌曲ID变化获取歌词
watch(
() => props.songId,
async (newId) => {
async (newId, _oldId, onCleanup) => {
if (!newId || !props.songInfo) return
let lyricText = ''
let parsedLyrics: LyricLine[] = []
// 创建一个符合 MusicItem 接口的对象,只包含必要的基本属性
// 竞态与取消控制,防止内存泄漏与过期结果覆盖
let active = true
const abort = new AbortController()
onCleanup(() => {
active = false
abort.abort()
})
// 工具函数:清洗响应式对象,避免序列化问题
const getCleanSongInfo = () => JSON.parse(JSON.stringify(toRaw(props.songInfo)))
// 工具函数:按来源解析逐字歌词
const parseCrLyricBySource = (source: string, text: string): LyricLine[] => {
return source === 'tx' ? parseQrc(text) : parseYrc(text)
}
// 工具函数:合并翻译到主歌词
const mergeTranslation = (base: LyricLine[], tlyric?: string): LyricLine[] => {
if (!tlyric || base.length === 0) return base
const translated = parseLrc(tlyric)
if (!translated || translated.length === 0) return base
// 将译文按 startTime-endTime 建立索引,便于精确匹配
const keyOf = (s: number, e: number) => `${s}-${e}`
const joinWords = (line: LyricLine) => (line.words || []).map((w) => w.word).join('')
const tMap = new Map<string, LyricLine>()
for (const tl of translated) {
tMap.set(keyOf(tl.startTime, tl.endTime), tl)
}
// 动态容差:与行时长相关,避免长/短行同一阈值导致误配
const baseTolerance = 300 // 上限
const ratioTolerance = 0.4 // 与行时长的比例
// 锚点对齐 + 顺序映射:以第一行为锚点,后续按索引顺序插入译文
const translatedSorted = translated.slice().sort((a, b) => a.startTime - b.startTime)
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 === '//' || !bl.words[0].word) continue
const text = joinWords(tl)
if (text) bl.translatedLyric = text
}
return base
}
}
// 未找到锚点:保持原样
return base
}
try {
// 检查是否为网易云音乐只有网易云才使用ttml接口
const isNetease =
props.songInfo && 'source' in props.songInfo && props.songInfo.source === 'wy'
const songinfo: any = _.cloneDeep(toRaw(props.songInfo))
console.log(songinfo)
if (isNetease) {
// 网易云音乐优先尝试ttml接口
const source =
props.songInfo && 'source' in props.songInfo ? (props.songInfo as any).source : 'kg'
let parsedLyrics: LyricLine[] = []
if (source === 'wy' || source === 'tx') {
// 网易云 / QQ 音乐:优先尝试 TTML同时准备备用方案
// 1. 立即启动 SDK (回退) 请求,但不 await
// 将其 Promise 存储在 sdkPromise 变量中
const sdkPromise = (async () => {
try {
const lyricData = await window.api.music.requestSdk('getLyric', {
source,
songInfo: _.cloneDeep(toRaw(props.songInfo)) as any
// 注意:这里的 abort.signal 是用于 TTML 的
// 如果 requestSdk 也支持 signal你可以考虑也传入
})
console.log('sdkPromise', lyricData)
// 依赖外部的 active 检查
if (!active) return null
let lyrics: null | LyricLine[] = null
if (lyricData?.crlyric) {
lyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
lyrics = parseLrc(lyricData.lyric)
}
lyrics = mergeTranslation(lyrics as any, lyricData?.tlyric)
// 如果 SDK 也拿不到歌词,返回 null
if (!lyrics || lyrics.length === 0) {
return null
}
return lyrics
} catch (err: any) {
// 如果 SDK 请求失败,抛出错误
// 这样当 TTML 也失败时,可以捕获到这个 SDK 错误
throw new Error(`SDK request failed: ${err.message}`)
}
})()
// 2. 尝试 TTML (主要) 请求
try {
const res = (await (
await fetch(`https://amll.bikonoo.com/ncm-lyrics/${newId}.ttml`)
).text()) as any
if (!res || res.length < 100) throw new Error('ttml 无歌词')
parsedLyrics = parseTTML(res).lines
console.log('搜索到ttml歌词', parsedLyrics)
} catch {
// ttml失败后使用新的歌词API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: 'wy',
songInfo: songinfo
})
console.log('网易云歌词数据:', lyricData)
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
console.log('网易云逐字歌词', lyricText)
parsedLyrics = parseYrc(lyricText)
console.log('使用网易云逐字歌词', parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log('使用网易云普通歌词', parsedLyrics)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log('网易云翻译歌词:', translatedline)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
const res = await (
await fetch(
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${newId}`,
{
signal: abort.signal // TTML 请求使用 abort signal
}
)
).text()
if (!active) return
if (!res || res.length < 100) {
throw new Error('ttml 无歌词') // 抛出错误以触发 catch
}
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 {
// 其他音乐平台直接使用新的歌词API
const source = props.songInfo && 'source' in props.songInfo ? props.songInfo.source : 'kg'
// 创建一个纯净的对象避免Vue响应式对象序列化问题
const cleanSongInfo = JSON.parse(JSON.stringify(toRaw(props.songInfo)))
} else if (source !== 'local') {
// 其他来源:直接统一歌词 API
const lyricData = await window.api.music.requestSdk('getLyric', {
source: source,
songInfo: cleanSongInfo
source,
songInfo: getCleanSongInfo()
})
console.log(`${source}歌词数据:`, lyricData)
if (!active) return
if (lyricData.crlyric) {
// 使用逐字歌词
lyricText = lyricData.crlyric
if (source === 'tx') {
parsedLyrics = parseQrc(lyricText)
} else {
parsedLyrics = parseYrc(lyricText)
}
console.log(`使用${source}逐字歌词`, parsedLyrics)
} else if (lyricData.lyric) {
lyricText = lyricData.lyric
parsedLyrics = parseLrc(lyricText)
console.log(`使用${source}普通歌词`, parsedLyrics)
if (lyricData?.crlyric) {
parsedLyrics = parseCrLyricBySource(source, lyricData.crlyric)
} else if (lyricData?.lyric) {
parsedLyrics = parseLrc(lyricData.lyric)
}
if (lyricData.tlyric) {
const translatedline = parseLrc(lyricData.tlyric)
console.log(`${source}翻译歌词:`, translatedline)
for (let i = 0; i < parsedLyrics.length; i++) {
if (translatedline[i] && translatedline[i].words[0]) {
parsedLyrics[i].translatedLyric = translatedline[i].words[0].word
}
}
}
}
if (parsedLyrics.length > 0) {
state.lyricLines = parsedLyrics
console.log('歌词加载成功', parsedLyrics.length)
parsedLyrics = mergeTranslation(parsedLyrics, lyricData?.tlyric)
} else {
state.lyricLines = []
console.log('未找到歌词或解析失败')
const text = (props.songInfo as any).lrc as string | null
if (text && (/^\[(\d+),\d+\]/.test(text) || /\(\d+,\d+,\d+\)/.test(text))) {
parsedLyrics = text ? (parseYrc(text) as any) : []
} else {
parsedLyrics = text ? (parseLrc(text) as any) : []
}
}
if (!active) return
state.lyricLines = parsedLyrics.length > 0 ? parsedLyrics : []
} catch (error) {
console.error('获取歌词失败:', error)
// 若已无效或已清理,避免写入与持有引用
if (!active) return
state.lyricLines = []
}
},
@@ -197,6 +302,7 @@ watch(
const bgRef = ref<BackgroundRenderRef | undefined>(undefined)
const lyricPlayerRef = ref<LyricPlayerRef | undefined>(undefined)
// 订阅音频事件,保持数据同步
const unsubscribeTimeUpdate = ref<(() => void) | undefined>(undefined)
const unsubscribePlay = ref<(() => void) | undefined>(undefined)
@@ -214,24 +320,51 @@ const useBlackText = ref(false)
async function updateTextColor() {
try {
useBlackText.value = await shouldUseBlackText(actualCoverImage.value)
console.log('使用黑色文本:', useBlackText.value)
} catch (error) {
console.error('获取对比色失败:', error)
useBlackText.value = false // 默认使用白色文本
}
}
const jumpTime = (e) => {
if (Audio.value.audio) Audio.value.audio.currentTime = e.line.getLine().startTime / 1000
}
// 监听封面图片变化
watch(() => actualCoverImage.value, updateTextColor, { immediate: true })
// 在全屏播放显示时阻止系统息屏
const blockerActive = ref(false)
watch(
() => props.show,
async (visible) => {
try {
if (visible && !blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.start?.()
blockerActive.value = true
} else if (!visible && blockerActive.value) {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
blockerActive.value = false
}
} catch (e) {
console.error('powerSaveBlocker 切换失败:', e)
}
},
{ immediate: true }
)
// 组件挂载时初始化
onMounted(() => {
updateTextColor()
console.log('组件挂载完成', bgRef.value, lyricPlayerRef.value)
})
// 组件卸载前清理订阅
onBeforeUnmount(() => {
onBeforeUnmount(async () => {
// 组件卸载时确保恢复系统息屏
if (blockerActive.value) {
try {
await (window as any)?.api?.powerSaveBlocker?.stop?.()
} catch {}
blockerActive.value = false
}
// 取消订阅以防止内存泄漏
if (unsubscribeTimeUpdate.value) {
unsubscribeTimeUpdate.value()
@@ -239,6 +372,8 @@ onBeforeUnmount(() => {
if (unsubscribePlay.value) {
unsubscribePlay.value()
}
bgRef.value?.bgRender?.dispose()
lyricPlayerRef.value?.lyricPlayer?.dispose()
})
// 监听音频URL变化
@@ -299,7 +434,7 @@ const lyricTranslateY = computed(() => {
:album-is-video="false"
:fps="30"
:flow-speed="4"
:has-lyric="state.lyricLines.length > 10 && playSetting.getBgPlaying"
:has-lyric="state.lyricLines.length > 10"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1"
/>
<!-- 全屏按钮 -->
@@ -360,16 +495,12 @@ const lyricTranslateY = computed(() => {
<div v-show="state.lyricLines.length > 0" class="right">
<LyricPlayer
ref="lyricPlayerRef"
:lyric-lines="props.show ? state.lyricLines : []"
:lyric-lines="props.show ? toRaw(state.lyricLines) : []"
:current-time="state.currentTime"
class="lyric-player"
:enable-spring="playSetting.getisJumpLyric"
:enable-scale="playSetting.getisJumpLyric"
@line-click="
(e) => {
if (Audio.audio) Audio.audio.currentTime = e.line.getLine().startTime / 1000
}
"
@line-click="jumpTime"
>
</LyricPlayer>
</div>

View File

@@ -1,5 +1,14 @@
<script setup lang="ts">
import { onMounted, onUnmounted, provide, ref, onActivated, onDeactivated } from 'vue'
import {
onMounted,
onUnmounted,
provide,
ref,
onActivated,
onDeactivated,
watch,
nextTick
} from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
const audioStore = ControlAudioStore()
@@ -17,6 +26,26 @@ onMounted(() => {
// window.api.ping(handleEnded)
})
/**
* 监听 URL 变化,先重置旧音频再加载新音频,避免旧解码/缓冲滞留
*/
watch(
() => audioStore.Audio.url,
async (newUrl) => {
const a = audioMeta.value
if (!a) return
try {
a.pause()
} catch {}
a.removeAttribute('src')
a.load()
await nextTick()
// 模板绑定会把 src 更新为 newUrl这里再触发一次 load
if (newUrl) {
a.load()
}
}
)
// 组件被激活时(从缓存中恢复)
onActivated(() => {
console.log('音频组件被激活')
@@ -71,22 +100,29 @@ const handlePlay = (): void => {
audioStore.publish('play')
}
let rafId: number | null = null
const startSetupInterval = (): void => {
if (rafId !== null) return
const onFrame = () => {
if (audioMeta.value && !audioMeta.value.paused) {
audioStore.publish('timeupdate')
audioStore.setCurrentTime((audioMeta.value && audioMeta.value.currentTime) || 0)
requestAnimationFrame(onFrame)
}
rafId = requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)
rafId = requestAnimationFrame(onFrame)
}
const handlePause = (): void => {
audioStore.Audio.isPlay = false
audioStore.publish('pause')
// 停止单实例 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
}
const handleError = (event: Event): void => {
@@ -112,8 +148,23 @@ const handleCanPlay = (): void => {
onUnmounted(() => {
// 组件卸载时清空所有订阅者
window.api.pingService.stop()
try {
window.api.pingService.stop()
} catch {}
// 停止 rAF
if (rafId !== null) {
try {
cancelAnimationFrame(rafId)
} catch {}
rafId = null
}
if (audioMeta.value) {
try {
audioMeta.value.pause()
} catch {}
audioMeta.value.removeAttribute('src')
audioMeta.value.load()
}
audioStore.clearAllSubscribers()
})
</script>

View File

@@ -8,8 +8,7 @@ import {
nextTick,
onActivated,
onDeactivated,
toRaw,
provide
toRaw
} from 'vue'
import { ControlAudioStore } from '@renderer/store/ControlAudio'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
@@ -23,240 +22,101 @@ import { getBestContrastTextColorWithOpacity } from '@renderer/utils/color/contr
import { PlayMode, type SongList } from '@renderer/types/audio'
import { MessagePlugin } from 'tdesign-vue-next'
import {
initPlaylistEventListeners,
destroyPlaylistEventListeners,
getSongRealUrl
} from '@renderer/utils/playlist/playlistManager'
import mediaSessionController from '@renderer/utils/audio/useSmtc'
songInfo,
playNext,
playPrevious,
updatePlayMode,
togglePlayPause,
isLoadingSong,
setVolume,
seekTo,
playSong,
playMode
} from '@renderer/utils/audio/globaPlayList'
import defaultCoverImg from '/default-cover.png'
import { downloadSingleSong } from '@renderer/utils/audio/download'
import { HeartIcon, DownloadIcon, CheckIcon, LockOnIcon } from 'tdesign-icons-vue-next'
import _ from 'lodash'
import { songListAPI } from '@renderer/api/songList'
const controlAudio = ControlAudioStore()
const localUserStore = LocalUserDetailStore()
const { Audio } = storeToRefs(controlAudio)
const { list, userInfo } = storeToRefs(localUserStore)
const { setCurrentTime, start, stop, setVolume, setUrl } = controlAudio
const {} = controlAudio
// 当前歌曲是否已在“我的喜欢”
const likeState = ref(false)
const isLiked = computed(() => likeState.value)
const refreshLikeState = async () => {
try {
if (!userInfo.value.lastPlaySongId) {
likeState.value = false
return
}
const favIdRes = await window.api.songList.getFavoritesId()
const favoritesId: string | null = (favIdRes && favIdRes.data) || null
if (!favoritesId) {
likeState.value = false
return
}
const hasRes = await songListAPI.hasSong(favoritesId, userInfo.value.lastPlaySongId)
likeState.value = !!(hasRes.success && hasRes.data)
} catch {
likeState.value = false
}
}
watch(
() => userInfo.value.lastPlaySongId,
() => refreshLikeState()
)
onMounted(() => refreshLikeState())
const showFullPlay = ref(false)
document.addEventListener('keydown', KeyEvent)
// 处理最小化右键的事件
const removeMusicCtrlListener = window.api.onMusicCtrl(() => {
togglePlayPause()
})
let timer: any = null
// 桌面歌词开关与锁定状态
const desktopLyricOpen = ref(false)
const desktopLyricLocked = ref(false)
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
// 桌面歌词按钮逻辑:
// - 若未打开:打开桌面歌词
// - 若已打开且锁定:先解锁,不关闭
// - 若已打开且未锁定:关闭桌面歌词
const toggleDesktopLyric = async () => {
try {
if (!desktopLyricOpen.value) {
window.electron?.ipcRenderer?.send?.('change-desktop-lyric', true)
desktopLyricOpen.value = true
// 恢复最新锁定状态
const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state')
desktopLyricLocked.value = !!lock
return
}
}, 100)
// 已打开
const lock = await window.electron?.ipcRenderer?.invoke?.('get-lyric-lock-state')
desktopLyricLocked.value = !!lock
if (desktopLyricLocked.value) {
// 先解锁,本次不关闭
window.electron?.ipcRenderer?.send?.('toogleDesktopLyricLock', false)
desktopLyricLocked.value = false
return
}
// 未锁定则关闭
window.electron?.ipcRenderer?.send?.('change-desktop-lyric', false)
desktopLyricOpen.value = false
} catch (e) {
console.error('切换桌面歌词失败:', e)
}
}
// 等待音频准备就绪
const waitForAudioReady = (): Promise<void> => {
return new Promise((resolve, reject) => {
const audio = Audio.value.audio
if (!audio) {
reject(new Error('音频元素未初始化'))
return
}
// 如果音频已经准备就绪
if (audio.readyState >= 3) {
// HAVE_FUTURE_DATA
resolve()
return
}
// 设置超时
const timeout = setTimeout(() => {
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载超时'))
}, 10000) // 10秒超时
const onCanPlay = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
resolve()
}
const onError = () => {
clearTimeout(timeout)
audio.removeEventListener('canplay', onCanPlay)
audio.removeEventListener('error', onError)
reject(new Error('音频加载失败'))
}
// 监听事件
audio.addEventListener('canplay', onCanPlay, { once: true })
audio.addEventListener('error', onError, { once: true })
})
}
// 存储待恢复的播放位置
let pendingRestorePosition = 0
let pendingRestoreSongId: number | string | null = null
// 播放位置恢复逻辑由全局播放管理器处理
// 记录组件被停用前的播放状态
let wasPlaying = false
// let wasPlaying = false
// let playbackPosition = 0
let isFull = false
// 播放指定歌曲
const playSong = async (song: SongList) => {
try {
// 设置加载状态
isLoadingSong.value = true
// 检查是否需要恢复播放位置(历史播放)
const isHistoryPlay =
song.songmid === userInfo.value.lastPlaySongId &&
userInfo.value.currentTime !== undefined &&
userInfo.value.currentTime > 0
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = song.songmid
console.log(`准备恢复播放位置: ${pendingRestorePosition}`)
// 清除历史位置,避免重复恢复
userInfo.value.currentTime = 0
} else {
pendingRestorePosition = 0
pendingRestoreSongId = null
}
// 更新当前播放歌曲ID
userInfo.value.lastPlaySongId = song.songmid
// 如果播放列表是打开的,滚动到当前播放歌曲
if (showPlaylist.value) {
nextTick(() => {
playlistDrawerRef.value?.scrollToCurrentSong()
})
}
// 更新歌曲信息并触发主题色更新
songInfo.value.name = song.name
songInfo.value.singer = song.singer
songInfo.value.albumName = song.albumName
songInfo.value.img = song.img
// 更新媒体会话元数据
mediaSessionController.updateMetadata({
title: song.name,
artist: song.singer,
album: song.albumName || '未知专辑',
artworkUrl: song.img || defaultCoverImg
})
// 确保主题色更新
let urlToPlay = ''
// 获取URL
// eslint-disable-next-line no-useless-catch
try {
urlToPlay = await getSongRealUrl(toRaw(song))
} catch (error) {
throw error
}
// 先停止当前播放
if (Audio.value.isPlay) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
}
// 设置URL这会触发音频重新加载
setUrl(urlToPlay)
// 等待音频准备就绪
await waitForAudioReady()
await setColor()
songInfo.value = {
...song
}
// // 短暂延迟确保音频状态稳定
// await new Promise((resolve) => setTimeout(resolve, 100))
// 开始播放
try {
start()
} catch (error) {
console.error('启动播放失败:', error)
// 如果是 AbortError尝试重新播放
if ((error as { name: string }).name === 'AbortError') {
console.log('检测到 AbortError尝试重新播放...')
await new Promise((resolve) => setTimeout(resolve, 200))
try {
const retryResult = start()
if (retryResult && typeof retryResult.then === 'function') {
await retryResult
}
} catch (retryError) {
console.error('重试播放也失败:', retryError)
throw retryError
}
} else {
throw error
}
}
} catch (error: any) {
console.error('播放歌曲失败:', error)
MessagePlugin.error('播放失败,原因:' + error.message)
} finally {
// 无论成功还是失败,都清除加载状态
isLoadingSong.value = false
}
}
provide('PlaySong', playSong)
// 歌曲信息
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
// const playMode = ref(PlayMode.SEQUENCE)
// 歌曲加载状态
const isLoadingSong = ref(false)
// 更新播放模式
const updatePlayMode = () => {
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
const currentIndex = modes.indexOf(playMode.value)
const nextIndex = (currentIndex + 1) % modes.length
playMode.value = modes[nextIndex]
// 更新用户信息
userInfo.value.playMode = playMode.value
}
// 获取播放模式图标类名
let playModeTip = ''
const playModeIconClass = computed(() => {
@@ -321,6 +181,19 @@ const handleVolumeDragEnd = () => {
window.removeEventListener('mouseup', handleVolumeDragEnd)
}
const handleVolumeWheel = (event: WheelEvent) => {
event.preventDefault()
const volumeStep = event.deltaY > 0 ? -5 : 5
const updatedVolume = Math.max(0, Math.min(100, volumeValue.value + volumeStep))
if (updatedVolume === volumeValue.value) {
return
}
volumeValue.value = updatedVolume
}
// 播放列表相关
const showPlaylist = ref(false)
const playlistDrawerRef = ref<InstanceType<typeof PlaylistDrawer> | null>(null)
@@ -346,179 +219,27 @@ const closePlaylist = () => {
}
// 播放上一首
const playPrevious = async () => {
if (list.value.length === 0) return
try {
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
let prevIndex
if (playMode.value === PlayMode.RANDOM) {
// 随机模式
prevIndex = Math.floor(Math.random() * list.value.length)
} else {
// 顺序模式或单曲循环模式
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
}
// 确保索引有效
if (prevIndex >= 0 && prevIndex < list.value.length) {
await playSong(list.value[prevIndex])
}
} catch (error) {
console.error('播放上一首失败:', error)
MessagePlugin.error('播放上一首失败')
}
}
// 播放下一首
const playNext = async () => {
if (list.value.length === 0) return
try {
// 单曲循环模式下,重新播放当前歌曲
if (playMode.value === PlayMode.SINGLE && currentSongId.value) {
const currentSong = list.value.find((song) => song.songmid === currentSongId.value)
if (currentSong) {
// 重新设置播放位置到开头
if (Audio.value.audio) {
Audio.value.audio.currentTime = 0
}
// 如果当前正在播放,继续播放;如果暂停,保持暂停
const startResult = start()
if (startResult && typeof startResult.then === 'function') {
await startResult
}
return
}
}
const currentIndex = list.value.findIndex((song) => song.songmid === currentSongId.value)
let nextIndex
if (playMode.value === PlayMode.RANDOM) {
// 随机模式
nextIndex = Math.floor(Math.random() * list.value.length)
} else {
// 顺序模式
nextIndex = (currentIndex + 1) % list.value.length
}
// 确保索引有效
if (nextIndex >= 0 && nextIndex < list.value.length) {
await playSong(list.value[nextIndex])
}
} catch (error) {
console.error('播放下一首失败:', error)
MessagePlugin.error('播放下一首失败')
}
}
// 上一首/下一首由全局播放管理器提供
// 定期保存当前播放位置
let savePositionInterval: number | null = null
let unEnded: () => any = () => {}
// 全局快捷控制事件由全局播放管理器处理
// 初始化播放器
onMounted(async () => {
console.log('加载')
// 初始化播放列表事件监听器
initPlaylistEventListeners(localUserStore, playSong)
// 初始化媒体会话控制器
if (Audio.value.audio) {
mediaSessionController.init(Audio.value.audio, {
play: async () => {
// 专门的播放函数,只处理播放逻辑
if (!Audio.value.isPlay) {
await handlePlay()
}
},
pause: async () => {
// 专门的暂停函数,只处理暂停逻辑
if (Audio.value.isPlay) {
await handlePause()
}
},
playPrevious: () => playPrevious(),
playNext: () => playNext()
})
}
// 监听音频结束事件,根据播放模式播放下一首
unEnded = controlAudio.subscribe('ended', () => {
window.requestAnimationFrame(() => {
console.log('播放结束')
playNext()
})
// 监听来自主进程的锁定状态广播
window.electron?.ipcRenderer?.on?.('toogleDesktopLyricLock', (_, lock) => {
desktopLyricLocked.value = !!lock
})
// 监听主进程通知关闭桌面歌词
window.electron?.ipcRenderer?.on?.('closeDesktopLyric', () => {
desktopLyricOpen.value = false
desktopLyricLocked.value = false
})
// 检查是否有上次播放的歌曲
// 检查是否有上次播放的歌曲
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
if (lastPlayedSong) {
songInfo.value = {
...lastPlayedSong
}
// 立即更新媒体会话元数据,让系统显示当前歌曲信息
mediaSessionController.updateMetadata({
title: lastPlayedSong.name,
artist: lastPlayedSong.singer,
album: lastPlayedSong.albumName || '未知专辑',
artworkUrl: lastPlayedSong.img || defaultCoverImg
})
// 如果有历史播放位置,设置为待恢复状态
if (!Audio.value.isPlay) {
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
pendingRestorePosition = userInfo.value.currentTime
pendingRestoreSongId = lastPlayedSong.songmid
console.log(`初始化时设置待恢复位置: ${pendingRestorePosition}`)
// 设置当前播放时间以显示进度条位置,但不清除历史记录
if (Audio.value.audio) {
Audio.value.audio.currentTime = userInfo.value.currentTime
}
}
// 通过工具函数获取歌曲URL
try {
const url = await getSongRealUrl(toRaw(lastPlayedSong))
setUrl(url)
} catch (error) {
console.error('获取上次播放歌曲URL失败:', error)
}
} else {
// 同步实际播放状态,避免误写为 playing
if (Audio.value.audio) {
mediaSessionController.updatePlaybackState(
Audio.value.audio.paused ? 'paused' : 'playing'
)
}
}
}
}
// 定期保存当前播放位置
savePositionInterval = window.setInterval(() => {
if (Audio.value.isPlay) {
userInfo.value.currentTime = Audio.value.currentTime
}
}, 1000) // 每1秒保存一次
})
// 组件卸载时清理
onUnmounted(() => {
destroyPlaylistEventListeners()
document.removeEventListener('keydown', KeyEvent)
if (savePositionInterval !== null) {
clearInterval(savePositionInterval)
}
if (removeMusicCtrlListener) {
removeMusicCtrlListener()
}
// 清理媒体会话控制器
mediaSessionController.cleanup()
unEnded()
window.electron?.ipcRenderer?.removeAllListeners?.('toogleDesktopLyricLock')
window.electron?.ipcRenderer?.removeAllListeners?.('closeDesktopLyric')
})
// 组件被激活时(从缓存中恢复)
@@ -527,39 +248,14 @@ onActivated(async () => {
if (isFull) {
showFullPlay.value = true
}
// 如果之前正在播放,恢复播放
// if (wasPlaying && Audio.value.url) {
// // 恢复播放位置
// if (Audio.value.audio && playbackPosition > 0) {
// setCurrentTime(playbackPosition)
// Audio.value.audio.currentTime = playbackPosition
// }
// // 恢复播放
// try {
// const startResult = start()
// if (startResult && typeof startResult.then === 'function') {
// await startResult
// }
// console.log('恢复播放成功')
// } catch (error) {
// console.error('恢复播放失败:', error)
// }
// }
})
// 组件被停用时(缓存但不销毁)
onDeactivated(() => {
console.log('PlayMusic组件被停用')
// 保存当前播放状态
wasPlaying = Audio.value.isPlay
// playbackPosition = Audio.value.currentTime
// 仅记录状态,不主动暂停,避免页面切换导致音乐暂停
// wasPlaying = Audio.value.isPlay
isFull = showFullPlay.value
// 如果正在播放,暂停播放但不改变状态标志
if (wasPlaying && Audio.value.audio) {
Audio.value.audio.pause()
console.log('暂时暂停播放,状态已保存')
}
})
// 监听用户信息变化,更新音量
@@ -579,6 +275,86 @@ const toggleFullPlay = () => {
showFullPlay.value = !showFullPlay.value
}
// 左侧操作:喜欢/取消喜欢(支持切换)
const onToggleLike = async () => {
try {
// 获取当前播放歌曲对象
const currentSong = list.value.find((s) => s.songmid === userInfo.value.lastPlaySongId)
if (!currentSong) {
MessagePlugin.warning('当前没有正在播放的歌曲')
return
}
// 读取持久化的“我的喜欢”歌单ID
const favIdRes = await window.api.songList.getFavoritesId()
let favoritesId: string | null = (favIdRes && favIdRes.data) || null
// 如果已有ID但歌单不存在则置空
if (favoritesId) {
const existsRes = await songListAPI.exists(favoritesId)
if (!existsRes.success || !existsRes.data) {
favoritesId = null
}
}
// 如果没有ID尝试查找同名歌单找不到则创建
if (!favoritesId) {
const searchRes = await songListAPI.search('我的喜欢', 'local')
if (searchRes.success && Array.isArray(searchRes.data)) {
const exact = searchRes.data.find((pl) => pl.name === '我的喜欢' && pl.source === 'local')
favoritesId = exact?.id || null
}
if (!favoritesId) {
const createRes = await songListAPI.create('我的喜欢', '', 'local')
if (!createRes.success || !createRes.data?.id) {
MessagePlugin.error(createRes.error || '创建“我的喜欢”失败')
return
}
favoritesId = createRes.data.id
}
// 持久化ID到主进程配置
await window.api.songList.setFavoritesId(favoritesId)
}
// 根据当前状态决定添加或移除
if (likeState.value) {
const removeRes = await songListAPI.removeSong(
favoritesId!,
userInfo.value.lastPlaySongId as any
)
if (removeRes.success && removeRes.data) {
likeState.value = false
// MessagePlugin.success('已取消喜欢')
} else {
MessagePlugin.error(removeRes.error || '取消喜欢失败')
}
} else {
const addRes = await songListAPI.addSongs(favoritesId!, [
_.cloneDeep(toRaw(currentSong)) as any
])
if (addRes.success) {
likeState.value = true
// MessagePlugin.success('已添加到“我的喜欢”')
} else {
MessagePlugin.error(addRes.error || '添加到“我的喜欢”失败')
}
}
} catch (error: any) {
console.error('切换喜欢状态失败:', error)
MessagePlugin.error('操作失败,请稍后重试')
}
}
const onDownload = async () => {
try {
await downloadSingleSong(_.cloneDeep(toRaw(songInfo.value)) as any)
MessagePlugin.success('开始下载当前歌曲')
} catch (e: any) {
console.error('下载失败:', e)
MessagePlugin.error('下载失败,请稍后重试')
}
}
// 进度条相关
const progressRef = ref<HTMLDivElement | null>(null)
const isDraggingProgress = ref(false)
@@ -602,70 +378,6 @@ const formatTime = (seconds: number) => {
const currentTimeFormatted = computed(() => formatTime(Audio.value.currentTime))
const durationFormatted = computed(() => formatTime(Audio.value.duration))
// 专门的播放函数
const handlePlay = async () => {
if (!Audio.value.url) {
// 如果没有URL但有播放列表尝试播放第一首歌
if (list.value.length > 0) {
await playSong(list.value[0])
} else {
MessagePlugin.warning('播放列表为空,请先添加歌曲')
}
return
}
try {
// 检查是否需要恢复历史播放位置
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
console.log(`恢复播放位置: ${pendingRestorePosition}`)
// 等待音频准备就绪
await waitForAudioReady()
// 设置播放位置
setCurrentTime(pendingRestorePosition)
if (Audio.value.audio) {
Audio.value.audio.currentTime = pendingRestorePosition
}
// 清除待恢复的位置
pendingRestorePosition = 0
pendingRestoreSongId = null
}
const startResult = start()
if (startResult && typeof startResult.then === 'function') {
await startResult
}
// 播放已开始后,同步 SMTC 状态
mediaSessionController.updatePlaybackState('playing')
} catch (error) {
console.error('播放失败:', error)
MessagePlugin.error('播放失败,请重试')
}
}
// 专门的暂停函数
const handlePause = async () => {
if (Audio.value.url && Audio.value.isPlay) {
const stopResult = stop()
if (stopResult && typeof stopResult.then === 'function') {
await stopResult
}
// 暂停后,同步 SMTC 状态
mediaSessionController.updatePlaybackState('paused')
}
}
// 播放/暂停切换
const togglePlayPause = async () => {
if (Audio.value.isPlay) {
await handlePause()
} else {
await handlePlay()
}
}
// 进度条拖动处理
const handleProgressClick = (event: MouseEvent) => {
if (!progressRef.value) return
@@ -678,11 +390,7 @@ const handleProgressClick = (event: MouseEvent) => {
tempProgressPercentage.value = percentage
const newTime = (percentage / 100) * Audio.value.duration
setCurrentTime(newTime)
if (Audio.value.audio) {
Audio.value.audio.currentTime = newTime
}
seekTo(newTime)
}
const handleProgressDragMove = (event: MouseEvent) => {
@@ -709,11 +417,7 @@ const handleProgressDragEnd = (event: MouseEvent) => {
const offsetX = Math.max(0, Math.min(event.clientX - rect.left, rect.width))
const percentage = (offsetX / rect.width) * 100
const newTime = (percentage / 100) * Audio.value.duration
setCurrentTime(newTime)
if (Audio.value.audio) {
Audio.value.audio.currentTime = newTime
}
seekTo(newTime)
isDraggingProgress.value = false
window.removeEventListener('mousemove', handleProgressDragMove)
@@ -729,22 +433,7 @@ const handleProgressDragStart = (event: MouseEvent) => {
window.addEventListener('mouseup', handleProgressDragEnd)
}
// 歌曲信息
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
songmid: null,
hash: '',
name: '欢迎使用CeruMusic 🎉',
singer: '可以配置音源插件来播放你的歌曲',
albumName: '',
albumId: '0',
source: '',
interval: '00:00',
img: '',
lrc: null,
types: [],
_types: {},
typeUrl: {}
})
// 歌曲信息由全局播放管理器提供
const maincolor = ref('var(--td-brand-color-5)')
const startmaincolor = ref('rgba(0, 0, 0, 1)')
const contrastTextColor = ref('rgba(0, 0, 0, .8)')
@@ -823,6 +512,36 @@ watch(showFullPlay, (val) => {
<div class="song-name">{{ songInfo.name }}</div>
<div class="artist-name">{{ songInfo.singer }}</div>
</div>
<div class="left-actions">
<t-tooltip :content="isLiked ? '已喜欢' : '喜欢'">
<t-button
class="control-btn"
variant="text"
shape="circle"
:disabled="!songInfo.songmid"
@click.stop="onToggleLike"
>
<heart-icon
:fill-color="isLiked ? ['#FF7878', '#FF7878'] : ''"
:stroke-color="isLiked ? [] : [contrastTextColor, contrastTextColor]"
:stroke-width="isLiked ? 0 : 2"
size="18"
/>
</t-button>
</t-tooltip>
<t-tooltip content="下载">
<t-button
class="control-btn"
variant="text"
shape="circle"
:disabled="!songInfo.songmid"
@click.stop="onDownload"
>
<DownloadIcon size="18" />
</t-button>
</t-tooltip>
</div>
</div>
<!-- 中间播放控制 -->
@@ -868,6 +587,7 @@ watch(showFullPlay, (val) => {
class="volume-control"
@mouseenter="showVolumeSlider = true"
@mouseleave="showVolumeSlider = false"
@wheel.prevent="handleVolumeWheel"
>
<button class="control-btn">
<shengyin style="width: 1.5em; height: 1.5em" />
@@ -893,6 +613,29 @@ watch(showFullPlay, (val) => {
</transition>
</div>
<!-- 桌面歌词开关按钮 -->
<t-tooltip
:content="
desktopLyricOpen ? (desktopLyricLocked ? '解锁歌词' : '关闭桌面歌词') : '打开桌面歌词'
"
>
<t-button
class="control-btn lyric-btn"
shape="circle"
variant="text"
:disabled="!songInfo.songmid"
@click.stop="toggleDesktopLyric"
>
<SvgIcon name="lyricOpen" size="18"></SvgIcon>
<transition name="fade" mode="out-in">
<template v-if="desktopLyricOpen">
<LockOnIcon v-if="desktopLyricLocked" key="lock" class="lyric-lock" size="8" />
<CheckIcon v-else key="check" class="lyric-check" size="8" />
</template>
</transition>
</t-button>
</t-tooltip>
<!-- 播放列表按钮 -->
<t-tooltip content="播放列表">
<n-badge :value="list.length" :max="99" color="#bbb">
@@ -1019,38 +762,37 @@ watch(showFullPlay, (val) => {
/* 进度条样式 */
.progress-bar-container {
width: 100%;
height: 4px;
--touch-range-height: 20px;
--play-line-height: 4px;
height: calc(var(--touch-range-height) + var(--play-line-height)); // 放大可点击区域,但保持视觉细
position: absolute;
// padding-top: 2px;
top: calc(var(--touch-range-height) / 2 * -1);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:has(.progress-handle.dragging, *:hover) {
// margin-bottom: 0;
height: 6px;
}
.progress-bar {
width: 100%;
height: 100%;
position: relative;
.progress-background {
// 视觉上的细轨道,垂直居中
.progress-background,
.progress-filled {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
height: var(--play-line-height);
top: 50%;
transform: translateY(-50%);
border-radius: 999px;
}
.progress-background {
background: transparent;
}
.progress-filled {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(to right, v-bind(startmaincolor), v-bind(maincolor) 80%);
border-radius: 999px;
}
.progress-handle {
@@ -1062,7 +804,6 @@ watch(showFullPlay, (val) => {
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
// transition: opacity 0.2s ease;
&:hover,
&:active,
@@ -1071,6 +812,20 @@ watch(showFullPlay, (val) => {
}
}
// 悬停或拖拽时,轻微加粗提升可见性
&:hover {
.progress-background,
.progress-filled {
height: 6px;
}
}
&:has(.progress-handle.dragging) {
.progress-background,
.progress-filled {
height: 6px;
}
}
&:hover .progress-handle {
opacity: 1;
}
@@ -1135,6 +890,38 @@ watch(showFullPlay, (val) => {
}
}
/* 左侧操作按钮 */
.left-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 12px;
.control-btn {
background: transparent;
border: none;
color: v-bind(contrastTextColor);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: 18px;
}
&:hover {
color: v-bind(hoverColor);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
/* 中间:播放控制 */
.center-controls {
display: flex;
@@ -1214,6 +1001,7 @@ watch(showFullPlay, (val) => {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.iconfont {
font-size: 18px;
@@ -1222,6 +1010,17 @@ watch(showFullPlay, (val) => {
&:hover {
color: v-bind(hoverColor);
}
&.lyric-btn .lyric-check,
&.lyric-btn .lyric-lock {
position: absolute;
right: -1px;
bottom: -1px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 0 2px #fff;
color: v-bind(maincolor);
}
}
}
}

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface LyricOption {
fontSize: number
mainColor: string
shadowColor: string
x: number
y: number
width: number
height: number
}
const loading = ref(false)
const saving = ref(false)
const option = ref<LyricOption>({
fontSize: 30,
mainColor: '#73BCFC',
shadowColor: 'rgba(255, 255, 255, 0.5)',
x: 0,
y: 0,
width: 800,
height: 180
})
const shadowRgb = ref<{ r: number; g: number; b: number }>({ r: 255, g: 255, b: 255 })
const mainHex = ref<string>('#73BCFC')
const shadowColorStr = ref<string>('rgba(255, 255, 255, 0.5')
const parseColorToRgb = (input: string) => {
const rgbaMatch = input?.match(/rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i)
if (rgbaMatch) {
return { r: Number(rgbaMatch[1]), g: Number(rgbaMatch[2]), b: Number(rgbaMatch[3]) }
}
const hexMatch = input?.match(/^#([0-9a-f]{6})$/i)
if (hexMatch) {
const hex = hexMatch[1]
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
}
}
return { r: 255, g: 255, b: 255 }
}
const onMainColorChange = (val: string) => {
mainHex.value = val
option.value.mainColor = val
}
const onShadowColorChange = (val: string) => {
shadowColorStr.value = val
option.value.shadowColor = val
}
const original = ref<LyricOption | null>(null)
const loadOption = async () => {
loading.value = true
try {
const res = await window.electron.ipcRenderer.invoke('get-desktop-lyric-option')
if (res) {
option.value = { ...option.value, ...res }
original.value = { ...option.value }
mainHex.value = option.value.mainColor || '#73BCFC'
shadowColorStr.value = option.value.shadowColor || 'rgba(255,255,255,0.5)'
shadowRgb.value = parseColorToRgb(shadowColorStr.value)
}
} catch (e) {
console.warn('加载桌面歌词配置失败:', e)
} finally {
loading.value = false
}
}
const applyOption = () => {
saving.value = true
try {
// 传入 callback=true 让桌面歌词窗口即时更新
const payload = { ...option.value, shadowColor: shadowColorStr.value }
window.electron.ipcRenderer.send('set-desktop-lyric-option', payload, true)
} finally {
setTimeout(() => (saving.value = false), 200)
}
}
const resetOption = () => {
if (!original.value) return
option.value = { ...original.value }
applyOption()
}
const toggleDesktopLyric = (enabled: boolean) => {
window.electron.ipcRenderer.send('change-desktop-lyric', enabled)
}
onMounted(() => {
loadOption()
})
</script>
<template>
<div class="lyric-style">
<div class="header">
<h3>桌面歌词样式</h3>
<p>自定义桌面歌词的字体大小颜色与阴影效果并可预览与即时应用</p>
</div>
<div class="controls">
<div class="row">
<div class="field">
<label>字体大小(px)</label>
<t-input-number v-model="option.fontSize" :min="12" :max="96" :step="1" />
</div>
<div class="field">
<label>主颜色</label>
<t-color-picker
v-model="mainHex"
:color-modes="['monochrome']"
format="HEX"
@change="onMainColorChange"
/>
</div>
<div class="field">
<label>阴影颜色</label>
<t-color-picker
v-model="shadowColorStr"
:color-modes="['monochrome']"
format="RGBA"
:enable-alpha="true"
@change="onShadowColorChange"
/>
</div>
</div>
<div class="row">
<div class="field">
<label>宽度</label>
<t-input-number v-model="option.width" :min="300" :max="1600" :step="10" />
</div>
<div class="field">
<label>高度</label>
<t-input-number v-model="option.height" :min="100" :max="600" :step="10" />
</div>
</div>
<div class="actions">
<t-button :loading="loading" theme="default" variant="outline" @click="loadOption"
>刷新</t-button
>
<t-button :loading="saving" theme="primary" @click="applyOption">应用到桌面歌词</t-button>
<t-button theme="default" @click="resetOption">还原</t-button>
<t-switch @change="toggleDesktopLyric($event as boolean)">显示桌面歌词</t-switch>
</div>
</div>
<div class="preview">
<div
class="preview-lyric"
:style="{
fontSize: option.fontSize + 'px',
color: mainHex,
textShadow: `0 0 6px ${shadowColorStr}`
}"
>
这是桌面歌词预览
</div>
</div>
</div>
</template>
<style scoped>
.lyric-style {
display: flex;
flex-direction: column;
gap: 16px;
}
.header h3 {
margin: 0 0 6px 0;
}
.controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.color-input {
width: 48px;
height: 32px;
padding: 0;
border: 1px solid var(--td-border-level-1-color);
border-radius: var(--td-radius-small);
background: var(--td-bg-color-container);
}
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.preview {
padding: 16px;
border: 1px dashed var(--td-border-level-1-color);
border-radius: var(--td-radius-medium);
background: var(--settings-preview-bg);
}
.preview-lyric {
text-align: center;
font-weight: 700;
}
</style>

View File

@@ -34,15 +34,20 @@ const menuList: MenuItem[] = [
path: '/home/find'
},
{
name: '本地',
icon: 'icon-music',
path: '/home/local'
name: '歌单',
icon: 'icon-yanchu',
path: '/home/songlist'
},
{
name: '最近',
icon: 'icon-shijian',
path: '/home/recent'
name: '本地',
icon: 'icon-shouye',
path: '/home/local'
}
// {
// name: '最近',
// icon: 'icon-shijian',
// path: '/home/recent'
// }
]
const menuActive = ref(0)
const router = useRouter()
@@ -606,8 +611,8 @@ const handleSuggestionSelect = (suggestion: any, _type: any) => {
.mainContent {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
// overflow-y: auto;
overflow: hidden;
position: relative;
height: 0;
/* 确保flex子元素能够正确计算高度 */

View File

@@ -16,6 +16,11 @@ const routes: RouteRecordRaw[] = [
name: 'find',
component: () => import('@renderer/views/music/find.vue')
},
{
path: 'songlist',
name: 'songlist',
component: () => import('@renderer/views/music/songlist.vue')
},
{
path: 'local',
name: 'local',
@@ -67,18 +72,11 @@ function setAnimate(routerObj: RouteRecordRaw[]) {
}
}
setAnimate(routes)
const option: RouterOptions = {
history: createWebHashHistory(),
routes,
scrollBehavior(_to_, _from_, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
routes
}
const router = createRouter(option)
export default router

View File

@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
currentTime: 0,
duration: 0,
volume: 80,
url: ''
url: '',
eventInit: false
})
// -------------------------------------------发布订阅逻辑------------------------------------------

View File

@@ -44,6 +44,7 @@ export type ControlAudioState = {
duration: number
volume: number
url: string
eventInit: boolean
}
export type SongList = playList

View File

@@ -1,9 +1,12 @@
// 全局音频管理器,用于管理音频源和分析器
class AudioManager {
private static instance: AudioManager
private audioSources = new WeakMap<HTMLAudioElement, MediaElementAudioSourceNode>()
private audioContexts = new WeakMap<HTMLAudioElement, AudioContext>()
private analysers = new Map<string, AnalyserNode>()
// 为每个 audioElement 复用一个分流器,避免重复断开重连主链路
private splitters = new WeakMap<HTMLAudioElement, GainNode>()
static getInstance(): AudioManager {
if (!AudioManager.instance) {
@@ -26,8 +29,15 @@ class AudioManager {
context = new (window.AudioContext || (window as any).webkitAudioContext)()
source = context.createMediaElementSource(audioElement)
// 连接到输出,确保音频能正常播放
source.connect(context.destination)
// 确保仅通过分流器连接,避免重复直连导致音量叠加
let splitter = this.splitters.get(audioElement)
if (!splitter) {
splitter = context.createGain()
splitter.gain.value = 1.0
source.connect(splitter)
splitter.connect(context.destination)
this.splitters.set(audioElement, splitter)
}
// 存储引用
this.audioSources.set(audioElement, source)
@@ -60,16 +70,19 @@ class AudioManager {
analyser.fftSize = fftSize
analyser.smoothingTimeConstant = 0.6
// 创建增益节点作为中介,避免直接断开主音频链
const gainNode = context.createGain()
gainNode.gain.value = 1.0
// 复用每个 audioElement 的分流器source -> splitter -> 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)
}
// 连接source -> gainNode -> analyser
// -> destination (保持音频播放)
source.disconnect() // 先断开所有连接
source.connect(gainNode)
gainNode.connect(context.destination) // 确保音频继续播放
gainNode.connect(analyser) // 连接到分析器
// 将分析器挂到分流器上,不影响主链路
splitter.connect(analyser)
// 存储分析器引用
this.analysers.set(id, analyser)
@@ -104,6 +117,15 @@ class AudioManager {
context.close()
}
// 断开并移除分流器
const splitter = this.splitters.get(audioElement)
if (splitter) {
try {
splitter.disconnect()
} catch {}
this.splitters.delete(audioElement)
}
this.audioSources.delete(audioElement)
this.audioContexts.delete(audioElement)

View File

@@ -2,6 +2,14 @@ import { NotifyPlugin, MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
import { useSettingsStore } from '@renderer/store/Settings'
import { toRaw, h } from 'vue'
import {
QUALITY_ORDER,
getQualityDisplayName,
buildQualityFormats,
getHighestQualityType,
compareQuality,
type KnownQuality
} from '@common/utils/quality'
interface MusicItem {
singer: string
@@ -18,44 +26,17 @@ interface MusicItem {
typeUrl: Record<string, any>
}
const qualityMap: Record<string, string> = {
'128k': '标准音质',
'192k': '高品音质',
'320k': '超高品质',
flac: '无损音质',
flac24bit: '超高解析',
hires: '高清臻音',
atmos: '全景环绕',
master: '超清母带'
}
const qualityKey = Object.keys(qualityMap)
// 创建音质选择弹窗
function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<string | null> {
return new Promise((resolve) => {
const LocalUserDetail = LocalUserDetailStore()
// 获取歌曲支持的音质列表
const availableQualities = songInfo.types || []
// 检查用户设置的音质是否为特殊音质
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(userQuality)
// 如果是特殊音质且用户支持,添加到选项中(不管歌曲是否有这个音质)
const availableQualities = buildQualityFormats(songInfo.types || [])
// 展示全部音质,但对超出用户最高音质的项做禁用呈现
const userMaxIndex = QUALITY_ORDER.indexOf(userQuality as KnownQuality)
const qualityOptions = [...availableQualities]
if (isSpecialQuality && LocalUserDetail.userSource.quality === userQuality) {
const hasSpecialQuality = availableQualities.some((q) => q.type === userQuality)
if (!hasSpecialQuality) {
qualityOptions.push({ type: userQuality, size: '源站无法得知此音质的文件大小' })
}
}
// 按音质优先级排序
qualityOptions.sort((a, b) => {
const aIndex = qualityKey.indexOf(a.type)
const bIndex = qualityKey.indexOf(b.type)
return bIndex - aIndex // 降序排列,高音质在前
})
// 按音质优先级排序(高→低)
qualityOptions.sort((a, b) => compareQuality(a.type, b.type))
const dialog = DialogPlugin.confirm({
header: '选择下载音质(可滚动)',
@@ -80,35 +61,48 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
msOverflowStyle: 'none'
}
},
qualityOptions.map((quality) =>
h(
qualityOptions.map((quality) => {
const idx = QUALITY_ORDER.indexOf(quality.type as KnownQuality)
const disabled = userMaxIndex !== -1 && idx !== -1 && idx < userMaxIndex
return h(
'div',
{
key: quality.type,
class: 'quality-item',
title: disabled ? '超出你的最高音质设置,已禁用' : undefined,
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 16px',
margin: '8px 0',
border: '1px solid #e7e7e7',
border: '1px solid ' + (disabled ? '#f0f0f0' : '#e7e7e7'),
borderRadius: '6px',
cursor: 'pointer',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: quality.type === userQuality ? '#e6f7ff' : '#fff'
backgroundColor:
quality.type === userQuality ? (disabled ? '#f5faff' : '#e6f7ff') : '#fff',
opacity: disabled ? 0.55 : 1
},
onClick: () => {
if (disabled) return
dialog.destroy()
resolve(quality.type)
},
onMouseenter: (e: MouseEvent) => {
if (disabled) return
const target = e.target as HTMLElement
target.style.backgroundColor = '#f0f9ff'
target.style.borderColor = '#1890ff'
},
onMouseleave: (e: MouseEvent) => {
const target = e.target as HTMLElement
if (disabled) {
target.style.backgroundColor =
quality.type === userQuality ? '#f5faff' : '#fff'
target.style.borderColor = '#f0f0f0'
return
}
target.style.backgroundColor =
quality.type === userQuality ? '#e6f7ff' : '#fff'
target.style.borderColor = '#e7e7e7'
@@ -122,17 +116,22 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
style: {
fontWeight: '500',
fontSize: '14px',
color: quality.type === userQuality ? '#1890ff' : '#333'
color:
quality.type === userQuality
? disabled
? '#8fbfff'
: '#1890ff'
: '#333'
}
},
qualityMap[quality.type] || quality.type
getQualityDisplayName(quality.type)
),
h(
'div',
{
style: {
fontSize: '12px',
color: '#999',
color: disabled ? '#bbb' : '#999',
marginTop: '2px'
}
},
@@ -145,7 +144,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
class: 'quality-size',
style: {
fontSize: '12px',
color: '#666',
color: disabled ? '#999' : '#666',
fontWeight: '500'
}
},
@@ -153,7 +152,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
)
]
)
)
})
)
]
),
@@ -166,6 +165,7 @@ function createQualityDialog(songInfo: MusicItem, userQuality: string): Promise<
async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
try {
console.log('开始下载', toRaw(songInfo))
const LocalUserDetail = LocalUserDetailStore()
const userQuality = LocalUserDetail.userSource.quality as string
const settingsStore = useSettingsStore()
@@ -186,68 +186,20 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
return
}
let quality = selectedQuality
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果选择的是特殊音质,先尝试下载
if (isSpecialQuality) {
try {
console.log(`尝试下载特殊音质: ${quality} - ${qualityMap[quality]}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const specialResult = await window.api.music.requestSdk('downloadSingleSong', {
pluginId: LocalUserDetail.userSource.pluginId?.toString() || '',
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
})
;(await tip).close()
// 如果成功获取特殊音质链接,处理结果并返回
if (specialResult) {
if (!Object.hasOwn(specialResult, 'path')) {
MessagePlugin.info(specialResult.message)
} else {
await NotifyPlugin.success({
title: '下载成功',
content: `${specialResult.message} 保存位置: ${specialResult.path}`
})
}
return
}
console.log(`下载${qualityMap[quality]}音质失败,重新选择音质`)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载失败,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
} catch (specialError) {
console.log(`下载${qualityMap[quality]}音质出错:`, specialError)
MessagePlugin.error('该音质下载失败,请重新选择音质')
// 特殊音质下载出错,重新弹出选择框
const retryQuality = await createQualityDialog(songInfo, userQuality)
if (!retryQuality) {
return
}
quality = retryQuality
}
}
let quality = selectedQuality as string
// 检查选择的音质是否超出歌曲支持的最高音质
const songMaxQuality = songInfo.types[songInfo.types.length - 1]?.type
if (songMaxQuality && qualityKey.indexOf(quality) > qualityKey.indexOf(songMaxQuality)) {
const songMaxQuality = getHighestQualityType(songInfo.types)
if (
songMaxQuality &&
QUALITY_ORDER.indexOf(quality as KnownQuality) <
QUALITY_ORDER.indexOf(songMaxQuality as KnownQuality)
) {
quality = songMaxQuality
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${qualityMap[quality]}`)
MessagePlugin.warning(`所选音质不可用,已自动调整为: ${getQualityDisplayName(quality)}`)
}
console.log(`使用音质下载: ${quality} - ${qualityMap[quality]}`)
console.log(`使用音质下载: ${quality} - ${getQualityDisplayName(quality)}`)
const tip = MessagePlugin.success('开始下载歌曲:' + songInfo.name)
const result = await window.api.music.requestSdk('downloadSingleSong', {
@@ -255,7 +207,8 @@ async function downloadSingleSong(songInfo: MusicItem): Promise<void> {
source: songInfo.source,
quality,
songInfo: toRaw(songInfo) as any,
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions)
tagWriteOptions: toRaw(settingsStore.settings.tagWriteOptions),
isCache: true
})
;(await tip).close()

View 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
}

View 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
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,13 @@ const qualityKey = Object.keys(qualityMap)
*/
export async function getSongRealUrl(song: SongList): Promise<string> {
try {
if ((song as any).source === 'local') {
const id = (song as any).songmid
const url = await (window as any).api.localMusic.getUrlById(id)
if (typeof url === 'object' && url?.error) throw new Error(url.error)
if (typeof url === 'string') return url
throw new Error('本地歌曲URL获取失败')
}
// 获取当前用户的信息
const LocalUserDetail = LocalUserDetailStore()
// 通过统一的request方法获取真实的播放URL
@@ -42,38 +49,6 @@ export async function getSongRealUrl(song: SongList): Promise<string> {
const settingsStore = useSettingsStore()
const isCache = settingsStore.settings.autoCacheMusic ?? true
// 检查是否为特殊音质(高清臻音、全景环绕或超清母带)
const isSpecialQuality = ['hires', 'atmos', 'master'].includes(quality)
// 如果是特殊音质,先尝试获取对应链接
if (isSpecialQuality) {
try {
console.log(`尝试获取特殊音质: ${quality} - ${qualityMap[quality]}`)
const specialUrlData = await window.api.music.requestSdk('getMusicUrl', {
pluginId: LocalUserDetail.userSource.pluginId as unknown as string,
source: song.source,
songInfo: song as any,
quality,
isCache
})
// 如果成功获取特殊音质链接,直接返回
if (
typeof specialUrlData === 'string' ||
(typeof specialUrlData === 'object' && !specialUrlData.error)
) {
console.log(`成功获取${qualityMap[quality]}链接`)
return specialUrlData as string
}
console.log(`获取${qualityMap[quality]}链接失败,回退到标准逻辑`)
// 如果获取特殊音质失败,继续执行原有逻辑
} catch (specialError) {
console.log(`获取${qualityMap[quality]}链接出错,回退到标准逻辑:`, specialError)
// 特殊音质获取失败,继续执行原有逻辑
}
}
// 原有逻辑:检查歌曲支持的最高音质
if (
qualityKey.indexOf(quality) >
@@ -115,16 +90,34 @@ export async function addToPlaylistAndPlay(
playSongCallback: (song: SongList) => Promise<void>
) {
try {
// 使用store的方法添加歌曲到第一位
localUserStore.addSongToFirst(song)
// 获取当前正在播放的歌曲索引
const currentId = localUserStore.userInfo?.lastPlaySongId
const currentIndex =
currentId !== undefined && currentId !== null
? localUserStore.list.findIndex((item: SongList) => item.songmid === currentId)
: -1
// 播放歌曲 - 确保正确处理Promise
// 如果目标歌曲已在列表中,先移除以避免重复
const existingIndex = localUserStore.list.findIndex(
(item: SongList) => item.songmid === song.songmid
)
if (existingIndex !== -1) {
localUserStore.list.splice(existingIndex, 1)
}
if (currentIndex !== -1) {
// 正在播放:插入到当前歌曲的下一首
localUserStore.list.splice(currentIndex + 1, 0, song)
} else {
// 未在播放:添加到第一位
localUserStore.addSongToFirst(song)
}
// 播放插入的歌曲
const playResult = playSongCallback(song)
if (playResult && typeof playResult.then === 'function') {
await playResult
}
// await MessagePlugin.success('已添加到播放列表并开始播放')
} catch (error: any) {
console.error('播放失败:', error)
if (error.message) {

View File

@@ -23,10 +23,3 @@
<PlayMusic />
</div>
</template>
<style lang="scss" scoped>
.animate__animated {
position: absolute;
width: 100%;
}
</style>

View File

@@ -221,6 +221,8 @@ onUnmounted(() => {
.find-container {
padding: 2rem;
width: 100%;
height: 100%;
overflow-y: auto;
margin: 0 auto;
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRaw, computed } from 'vue'
import { ref, onMounted, toRaw, computed } from 'vue'
import { useRoute } from 'vue-router'
import { MessagePlugin, DialogPlugin } from 'tdesign-vue-next'
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
@@ -27,6 +27,10 @@ const LocalUserDetail = LocalUserDetailStore()
// 响应式状态
const songs = ref<MusicItem[]>([])
const loading = ref(true)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = 50
const currentSong = ref<MusicItem | null>(null)
const isPlaying = ref(false)
const playlistInfo = ref({
@@ -60,8 +64,8 @@ const fetchPlaylistSongs = async () => {
// 处理本地歌单
await fetchLocalPlaylistSongs()
} else {
// 处理网络歌单
await fetchNetworkPlaylistSongs()
// 处理网络歌单(重置并加载第一页)
await fetchNetworkPlaylistSongs(true)
}
} catch (error) {
console.error('获取歌单歌曲失败:', error)
@@ -77,20 +81,7 @@ const fetchLocalPlaylistSongs = async () => {
const result = await window.api.songList.getSongs(playlistInfo.value.id)
if (result.success && result.data) {
songs.value = result.data.map((song: any) => ({
singer: song.singer || '未知歌手',
name: song.name || '未知歌曲',
albumName: song.albumName || '未知专辑',
albumId: song.albumId || 0,
source: song.source || 'local',
interval: song.interval || '0:00',
songmid: song.songmid,
img: song.img || '',
lrc: song.lrc || null,
types: song.types || [],
_types: song._types || {},
typeUrl: song.typeUrl || {}
}))
songs.value = result.data
// 更新歌单信息中的歌曲总数
playlistInfo.value.total = songs.value.length
@@ -116,22 +107,43 @@ const fetchLocalPlaylistSongs = async () => {
}
}
// 获取网络歌单歌曲
const fetchNetworkPlaylistSongs = async () => {
/**
* 获取网络歌单歌曲,支持重置与分页追加
* @param reset 是否重置为第一页
*/
const fetchNetworkPlaylistSongs = async (reset = false) => {
try {
// 调用API获取歌单详情和歌曲列表
// 并发保护:首次加载使用 loading分页加载使用 loadingMore
if ((reset && !loading.value) || (!reset && loadingMore.value)) return
if (reset) {
currentPage.value = 1
hasMore.value = true
songs.value = []
loading.value = true
} else {
if (!hasMore.value) return
loadingMore.value = true
}
const result = (await window.api.music.requestSdk('getPlaylistDetail', {
source: playlistInfo.value.source,
id: playlistInfo.value.id,
page: 1
page: currentPage.value
})) as any
const limit = Number(result?.limit ?? pageSize)
console.log(result)
if (result && result.list) {
songs.value = result.list
if (result && Array.isArray(result.list)) {
const newList = result.list
// 获取歌曲封面
setPic(0, playlistInfo.value.source)
if (reset) {
songs.value = newList
} else {
songs.value = [...songs.value, ...newList]
}
// 获取新增歌曲封面
setPic((currentPage.value - 1) * limit, playlistInfo.value.source)
// 如果API返回了歌单详细信息更新歌单信息
if (result.info) {
@@ -143,10 +155,28 @@ const fetchNetworkPlaylistSongs = async () => {
total: result.info.total || playlistInfo.value.total
}
}
// 更新分页状态
currentPage.value += 1
const total = result.info?.total ?? playlistInfo.value.total ?? 0
if (total) {
hasMore.value = songs.value.length < total
} else {
hasMore.value = newList.length >= limit
}
} else {
hasMore.value = false
}
} catch (error) {
console.error('获取网络歌单失败:', error)
songs.value = []
if (reset) songs.value = []
hasMore.value = false
} finally {
if (reset) {
loading.value = false
} else {
loadingMore.value = false
}
}
}
@@ -389,45 +419,44 @@ const handleShufflePlaylist = () => {
}
})
}
// 滚动事件处理
/**
* 滚动事件处理:更新头部紧凑状态,并在接近底部时触发分页加载
*/
const handleScroll = (event?: Event) => {
let scrollTop = 0
let scrollHeight = 0
let clientHeight = 0
if (event && event.target) {
scrollTop = (event.target as HTMLElement).scrollTop
const target = event.target as HTMLElement
scrollTop = target.scrollTop
scrollHeight = target.scrollHeight
clientHeight = target.clientHeight
} else if (scrollContainer.value) {
scrollTop = scrollContainer.value.scrollTop
scrollHeight = scrollContainer.value.scrollHeight
clientHeight = scrollContainer.value.clientHeight
}
scrollY.value = scrollTop
// 当滚动超过100px时启用紧凑模式
isHeaderCompact.value = scrollY.value > 100
// 触底加载(参考 search.vue
if (
scrollHeight > 0 &&
scrollHeight - scrollTop - clientHeight < 100 &&
!loadingMore.value &&
hasMore.value &&
!isLocalPlaylist.value
) {
fetchNetworkPlaylistSongs(false)
}
}
// 组件挂载时获取数据
onMounted(() => {
fetchPlaylistSongs()
// 延迟添加滚动事件监听,等待 SongVirtualList 组件渲染完成
setTimeout(() => {
// 查找 SongVirtualList 内部的虚拟滚动容器
const virtualListContainer = document.querySelector('.virtual-scroll-container')
if (virtualListContainer) {
scrollContainer.value = virtualListContainer as HTMLElement
virtualListContainer.addEventListener('scroll', handleScroll, { passive: true })
console.log('滚动监听器已添加到:', virtualListContainer)
} else {
console.warn('未找到虚拟滚动容器')
}
}, 200)
})
// 组件卸载时清理事件监听
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,7 @@ const formatPlayTime = (timeStr: string): string => {
<template>
<div class="recent-container">
<div>待开发 作者正在麻溜赶代码 奈何还有期中考 不要抽我呀</div>
<!-- 页面标题和操作 -->
<div class="page-header">
<div class="header-left">
@@ -210,7 +211,9 @@ const formatPlayTime = (timeStr: string): string => {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
// min-height: 100%;
height: 100%;
overflow-y: auto;
}
.page-header {

View File

@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden auto;
}
.search-header {

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ import DirectorySettings from '@renderer/components/Settings/DirectorySettings.v
import MusicCache from '@renderer/components/Settings/MusicCache.vue'
import AIFloatBallSettings from '@renderer/components/Settings/AIFloatBallSettings.vue'
import ThemeSelector from '@renderer/components/ThemeSelector.vue'
import DesktopLyricStyle from '@renderer/components/Settings/DesktopLyricStyle.vue'
import Versions from '@renderer/components/Versions.vue'
import { useAutoUpdate } from '@renderer/composables/useAutoUpdate'
import { playSetting as usePlaySetting } from '@renderer/store/playSetting'
@@ -419,6 +420,10 @@ const getTagOptionsStatus = () => {
<h3>应用主题色</h3>
<ThemeSelector />
</div>
<div class="setting-group">
<DesktopLyricStyle />
</div>
</div>
<!-- AI 功能设置 -->

797
src/web/lyric.html Normal file
View File

@@ -0,0 +1,797 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>澜音 - 桌面歌词</title>
<style>
* {
margin: 0;
padding: 0;
user-select: none;
box-sizing: border-box;
-webkit-user-drag: none;
}
:root {
--font-size: 30;
--main-color: #73bcfc;
--shadow-color: rgba(255, 255, 255, 0.5);
--next-color: rgba(255, 255, 255, 0.68);
}
body {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 12px;
cursor: pointer;
color: var(--main-color);
overflow: hidden;
transition: opacity 0.3s;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 16px;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
z-index: 0;
opacity: 0;
cursor: move;
transition: opacity 0.3s;
}
&:hover {
&::after {
opacity: 1;
}
header {
.meta {
opacity: 0;
}
.tools {
opacity: 1;
}
}
}
&.lock-lyric {
cursor: none;
/* 鼠标穿透 */
pointer-events: none;
* {
pointer-events: none;
}
&::after {
opacity: 0;
}
&:hover {
opacity: 0;
}
header {
.meta {
opacity: 1;
}
.tools {
opacity: 0;
}
}
}
}
header {
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
.meta {
display: flex;
flex-direction: column;
align-items: center;
font-size: 14px;
opacity: 0.9;
transition: opacity 0.3s;
}
.tools {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
opacity: 0;
transition: opacity 0.3s;
gap: 8px;
.item {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 8px;
cursor: pointer;
transition:
transform 0.3s,
background-color 0.3s;
&.hidden {
display: none;
}
&:hover {
background-color: rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.95);
}
svg {
width: 24px;
height: 24px;
}
}
}
#song-artist {
margin-top: 4px;
font-size: 12px;
opacity: 0.8;
}
}
main {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 12px;
margin: 12px;
z-index: 1;
max-width: 100%;
pointer-events: auto;
width: 100%;
#lyric-text {
font-size: calc(var(--font-size) * 1px);
font-weight: bold;
}
#lyric-tran {
font-size: calc(var(--font-size) * 1px - 5px);
margin-top: 8px;
opacity: 0.6;
}
}
span {
padding: 0 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 0 4px var(--shadow-color);
transition: opacity 0.3s;
/* animation: 15s wordsLoop linear infinite normal; */
}
@keyframes wordsLoop {
0% {
transform: translateX(0px);
}
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>
</head>
<body>
<header>
<div class="meta">
<span id="song-name">CeruMusic澜音</span>
<span id="song-artist">未知艺术家</span>
</div>
<div class="tools" id="tools">
<div id="show-app" class="item" title="打开应用">
<svg
width="1200"
height="1200"
viewBox="0 0 1200 1200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="1200" height="1200" rx="379" fill="white" />
<path
d="M957.362 204.197C728.535 260.695 763.039 192.264 634.41 175.368C451.817 151.501 504.125 315.925 504.125 315.925L630.545 673.497C591.211 654.805 544.287 643.928 494.188 643.928C353.275 643.928 239 729.467 239 834.964C239 940.567 353.137 1026 494.188 1026C635.1 1026 749.375 940.461 749.375 834.964C749.375 832.218 749.237 829.473 749.099 826.727C749.513 825.988 749.789 825.143 750.065 824.087C757.932 789.449 634.272 348.345 634.272 348.345C634.272 348.345 764.971 401.886 860.89 351.936C971.163 294.699 964.953 202.402 957.362 204.197Z"
fill="url(#paint0_linear_4_16)"
stroke="#29293A"
stroke-opacity="0.23"
/>
<defs>
<linearGradient
id="paint0_linear_4_16"
x1="678.412"
y1="-1151.29"
x2="796.511"
y2="832.071"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.572115" stop-color="#B8F1ED" />
<stop offset="0.9999" stop-color="#B8F1CC" />
</linearGradient>
</defs>
</svg>
</div>
<div id="font-size-reduce" class="item" title="缩小字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M10.5 7h-2L3 21h2.2l1.1-3h6.2l1.1 3H16zm-3.4 9l2.4-6.3l2.4 6.3zM22 7h-8V5h8z"
/>
</svg>
</div>
<div id="font-size-add" class="item" title="放大字体">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8.5 7h2L16 21h-2.4l-1.1-3H6.3l-1.1 3H3zm-1.4 9h4.8L9.5 9.7zM22 5v2h-3v3h-2V7h-3V5h3V2h2v3z"
/>
</svg>
</div>
<div id="play-prev" class="item" title="上一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M7 6c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1s-1-.45-1-1V7c0-.55.45-1 1-1m3.66 6.82l5.77 4.07c.66.47 1.58-.01 1.58-.82V7.93c0-.81-.91-1.28-1.58-.82l-5.77 4.07a1 1 0 0 0 0 1.64"
/>
</svg>
</div>
<!-- 播放暂停 -->
<div id="pause" class="item hidden" title="暂停">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2m6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2"
/>
</svg>
</div>
<div id="play" class="item" title="播放">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18a1 1 0 0 0 0-1.69L9.54 5.98A.998.998 0 0 0 8 6.82"
/>
</svg>
</div>
<div id="play-next" class="item" title="下一首">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m7.58 16.89l5.77-4.07c.56-.4.56-1.24 0-1.63L7.58 7.11C6.91 6.65 6 7.12 6 7.93v8.14c0 .81.91 1.28 1.58.82M16 7v10c0 .55.45 1 1 1s1-.45 1-1V7c0-.55-.45-1-1-1s-1 .45-1 1"
/>
</svg>
</div>
<!-- 锁定 -->
<div id="lock-lyric" class="item" title="锁定/解锁">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2M9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9zm9 14H6V10h12zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2"
/>
</svg>
</div>
<!-- 关闭 -->
<div id="close-lyric" class="item" title="关闭">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z"
/>
</svg>
</div>
</div>
</header>
<main id="lyric-content">
<span id="lyric-text" style="display: none">该歌曲暂无歌词</span>
<span id="lyric-tran" style="display: none"></span>
<div id="lines-root" class="lines-root">
<div class="line line-a left">
<div class="text"></div>
<div class="tran"></div>
</div>
<div class="line line-b right">
<div class="text"></div>
<div class="tran"></div>
</div>
</div>
</main>
<script>
class LyricsWindow {
constructor() {
// 获取元素
this.songNameDom = document.getElementById('song-name')
this.songArtistDom = document.getElementById('song-artist')
this.lyricContentDom = document.getElementById('lyric-content')
this.lyricTextDom = document.getElementById('lyric-text')
this.lyricTranDom = document.getElementById('lyric-tran')
this.pauseDom = document.getElementById('pause')
this.playDom = document.getElementById('play')
// 窗口位置
this.isDragging = false
this.startX = 0
this.startY = 0
this.startWinX = 0
this.startWinY = 0
this.winWidth = 0
this.winHeight = 0
// 临时变量
// this.lyricIndex = -1;
// 每首歌起始基准索引,用于保证第一句总在左侧
this._baseIndex = null
// 上一索引与延迟更新队列(仅延迟“上一行->下一行”的替换)
this._lastIndex = -1
this._pendingUpnext = null // { wrapEl, textEl, tranEl, data }
this._lastLyricPayload = { lyric: [] }
// 初始化
this.restoreOptions()
this.menuClick()
this.setupIPCListeners()
this.setupWindowDragListeners()
this.setupMutationObserver()
}
// 准备态渲染:展示第 1、2 句左右铺开,不标记 current/upnext
renderPrepare() {
const payload = this._lastLyricPayload || { lyric: [] }
const lines = payload.lyric || []
if (!Array.isArray(lines) || lines.length === 0) return
const first = lines[0] || { content: '', tran: '' }
const second = lines[1] || { content: '', tran: '' }
const root = document.getElementById('lines-root')
const lineA = root.querySelector('.line-a')
const lineB = root.querySelector('.line-b')
const aText = lineA.querySelector('.text')
const aTran = lineA.querySelector('.tran')
const bText = lineB.querySelector('.text')
const bTran = lineB.querySelector('.tran')
lineA.classList.remove('current', 'upnext')
lineB.classList.remove('current', 'upnext')
const updateSide = (wrapEl, textEl, tranEl, data) => {
const newText = data?.content || ''
const newTran = data?.tran || ''
if (textEl.textContent !== newText || tranEl.textContent !== newTran) {
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
}
}
updateSide(lineA, aText, aTran, first)
updateSide(lineB, bText, bTran, second)
}
// 基于当前行索引的“AB 交错 + 预显下一句”规则:
// 当前 index 偶数 -> 左=当前句(index),右=下一句(index+1)
// 当前 index 奇数 -> 左=下一句(index+1),右=当前句(index)
renderByIndex(index) {
const payload = this._lastLyricPayload || { lyric: [] }
const lines = payload.lyric || []
if (!Array.isArray(lines) || lines.length === 0) return
if (typeof index !== 'number' || index < 0 || index >= lines.length) return
// 锁定基准:确保每首歌的第一句渲染在左侧
if (this._baseIndex === null) this._baseIndex = index
const cur = lines[index] || { content: '纯音乐,请欣赏', tran: '' }
const next = lines[index + 1] || { content: '', tran: '' }
const root = document.getElementById('lines-root')
const lineA = root.querySelector('.line-a')
const lineB = root.querySelector('.line-b')
const aText = lineA.querySelector('.text')
const aTran = lineA.querySelector('.tran')
const bText = lineB.querySelector('.text')
const bTran = lineB.querySelector('.tran')
const even = (index - this._baseIndex) % 2 === 0
// 标记当前句与下一句以应用不同颜色样式(立即切换高亮,不等 30%
lineA.classList.remove('current', 'upnext')
lineB.classList.remove('current', 'upnext')
let currentWrap, currentText, currentTran, upnextWrap, upnextText, upnextTran
if (even) {
// 左:当前;右:下一
lineA.classList.add('current')
lineB.classList.add('upnext')
currentWrap = lineA
currentText = aText
currentTran = aTran
upnextWrap = lineB
upnextText = bText
upnextTran = bTran
} else {
// 左:下一;右:当前
lineA.classList.add('upnext')
lineB.classList.add('current')
currentWrap = lineB
currentText = bText
currentTran = bTran
upnextWrap = lineA
upnextText = aText
upnextTran = aTran
}
const updateSide = (wrapEl, textEl, tranEl, data) => {
const newText = data?.content || ''
const newTran = data?.tran || ''
const oldText = textEl.textContent || ''
const oldTran = tranEl.textContent || ''
if (newText === oldText && newTran === oldTran) return
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
}
// 1) 立即更新“当前句”内容,保证开始朗读就高亮且正确
updateSide(currentWrap, currentText, currentTran, cur)
// 2) 对“上一行 -> 下一行”的替换延迟到 30% 进度
const desiredUpText = next?.content || ''
const desiredUpTran = next?.tran || ''
const upOldText = upnextText.textContent || ''
const upOldTran = upnextTran.textContent || ''
// 若 upnext 目前已是下一句,则无需延迟
if (desiredUpText === upOldText && desiredUpTran === upOldTran) {
this._pendingUpnext = null
} else {
// 仅记录待更新,不立刻替换
this._pendingUpnext = {
index,
wrapEl: upnextWrap,
textEl: upnextText,
tranEl: upnextTran,
data: next
}
}
}
// 获取配置
async restoreOptions() {
try {
const defaultOptions = await window.electron.ipcRenderer.invoke(
'get-desktop-lyric-option'
)
if (defaultOptions) this.changeOptions(defaultOptions)
return defaultOptions
} catch (error) {
console.error('Failed to restore options:', error)
}
}
// 修改配置
changeOptions(options, callback = true) {
if (!options) return
const { fontSize, mainColor, shadowColor } = options
const root = document.documentElement
root.style.setProperty('--font-size', fontSize)
root.style.setProperty('--main-color', mainColor)
root.style.setProperty('--shadow-color', shadowColor)
// 基于主色生成灰度化的 next 颜色(使用 color-mix 提升兼容/观感)
try {
const next = `color-mix(in srgb, ${mainColor} 35%, white 65%)`
root.style.setProperty('--next-color', next)
} catch (e) {
// 退化:若不支持 color-mix则使用固定透明度的主色
root.style.setProperty('--next-color', mainColor)
}
if (callback) window.electron.ipcRenderer.send('set-desktop-lyric-option', options)
}
// 菜单点击事件
menuClick() {
const toolsDom = document.getElementById('tools')
if (!toolsDom) return
// 菜单项点击
toolsDom.addEventListener('click', async (event) => {
const target = event.target.closest('div')
if (!target) return
console.log(target)
const id = target.id
if (!id) return
// 获取配置
const options = await this.restoreOptions()
switch (id) {
case 'show-app': {
window.electron.ipcRenderer.send('win-show')
break
}
case 'font-size-add': {
let fontSize = options.fontSize
if (fontSize < 60) {
fontSize++
this.changeOptions({ ...options, fontSize })
}
break
}
case 'font-size-reduce': {
let fontSize = options.fontSize
if (fontSize > 10) {
fontSize--
this.changeOptions({ ...options, fontSize })
}
break
}
case 'play': {
window.electron.ipcRenderer.send('send-main-event', 'play')
break
}
case 'pause': {
window.electron.ipcRenderer.send('send-main-event', 'pause')
break
}
case 'play-prev': {
window.electron.ipcRenderer.send('send-main-event', 'playPrev')
break
}
case 'play-next': {
window.electron.ipcRenderer.send('send-main-event', 'playNext')
break
}
case 'close-lyric': {
window.electron.ipcRenderer.send('closeDesktopLyric')
break
}
case 'lock-lyric': {
const locked = !document.body.classList.contains('lock-lyric')
document.body.classList.toggle('lock-lyric', locked)
window.electron.ipcRenderer.send('toogleDesktopLyricLock', locked)
break
}
default:
break
}
})
}
// 监听 IPC 事件
setupIPCListeners() {
window.electron.ipcRenderer.on('play-song-change', (_, title) => {
if (!title) return
const [songName, songArtist] = title.split(' - ')
this.songNameDom.innerHTML = songName
this.songArtistDom.innerHTML = songArtist
// 每首歌切换时重置基准索引,确保首句在左侧
this._baseIndex = null
this.updateLyrics(title)
})
window.electron.ipcRenderer.on('play-lyric-change', (_, lyricData) => {
if (!lyricData) return
this.parsedLyricsData(lyricData)
})
// 接收当前行索引(若主进程/渲染端已推送 index并按奇偶切换 A/B 行,避免“右侧唱完才换左侧”的滞后问题
window.electron.ipcRenderer.on('play-lyric-index', (_, index) => {
this._lastIndex = this._lastIndex ?? -1
if (typeof index !== 'number' || index === this._lastIndex) return
// 准备态index === -1先展示第 1、2 句左右铺开(不打标 current/upnext
if (index === -1) {
this._baseIndex = null
this._lastIndex = index
this.renderPrepare()
return
}
// 第一次收到该首歌的有效 index锁定基准保证奇偶性从 index 开始算起时首句落在左侧
if (this._baseIndex === null) this._baseIndex = index
this._lastIndex = index
this.renderByIndex(index)
})
window.electron.ipcRenderer.on('play-status-change', (_, status) => {
this.playDom.classList.toggle('hidden', status)
this.pauseDom.classList.toggle('hidden', !status)
})
// 进度事件:用于 30% 后再替换上一侧为“下一句”
window.electron.ipcRenderer.on('play-lyric-progress', (_, payload) => {
try {
const { index, progress } = payload || {}
if (!this._pendingUpnext) return
// 仅当仍在同一行朗读时触发
if (typeof index !== 'number' || index !== this._pendingUpnext.index) return
if (typeof progress !== 'number' || progress < 0.3) return
const { wrapEl, textEl, tranEl, data } = this._pendingUpnext
const newText = data?.content || ''
const newTran = data?.tran || ''
if (textEl.textContent === newText && tranEl.textContent === newTran) {
this._pendingUpnext = null
return
}
// 执行替换与入场动画
textEl.textContent = newText
tranEl.textContent = newTran
wrapEl.style.animation = 'none'
void wrapEl.offsetWidth
wrapEl.style.animation = 'lyricEnter 0.35s ease'
this._pendingUpnext = null
} catch {}
})
// 配置变化
window.electron.ipcRenderer.on('desktop-lyric-option-change', (_, options) => {
this.changeOptions(options, false)
})
// 歌词锁定(仅更新样式,不再回传,避免事件循环)
window.electron.ipcRenderer.on('toogleDesktopLyricLock', (_, lock) => {
document.body.classList.toggle('lock-lyric', lock)
})
}
// 解析歌词
parsedLyricsData(lyricData) {
if (!this.lyricContentDom || !this.lyricTextDom) return
const { index, lyric } = lyricData
// 缓存全集合,等待 index 推送来决定显示侧(奇偶)
this._lastLyricPayload = { lyric }
if (!lyric || lyric.length === 0) {
this.updateLyrics()
return
}
// 如果带 index则立即按规则渲染否则等待 play-lyric-index桥接侧也会周期推 index
if (typeof index === 'number' && index >= 0 && index < lyric.length) {
this.renderByIndex(index)
}
}
// 拖拽窗口
setupWindowDragListeners() {
document.addEventListener('mousedown', this.startDrag.bind(this))
document.addEventListener('mousemove', this.dragWindow.bind(this))
document.addEventListener('mouseup', this.endDrag.bind(this))
}
// 开始拖拽
async startDrag(event) {
this.isDragging = true
const { screenX, screenY } = event
const {
x: winX,
y: winY,
width,
height
} = await window.electron.ipcRenderer.invoke('get-window-bounds')
this.startX = screenX
this.startY = screenY
this.startWinX = winX
this.startWinY = winY
this.winWidth = width
this.winHeight = height
}
// 拖拽
async dragWindow(event) {
if (!this.isDragging) return
const { screenX, screenY } = event
let newWinX = this.startWinX + (screenX - this.startX)
let newWinY = this.startWinY + (screenY - this.startY)
const { width: screenWidth, height: screenHeight } =
await window.electron.ipcRenderer.invoke('get-screen-size')
newWinX = Math.max(0, Math.min(screenWidth - this.winWidth, newWinX))
newWinY = Math.max(0, Math.min(screenHeight - this.winHeight, newWinY))
window.electron.ipcRenderer.send(
'move-window',
newWinX,
newWinY,
this.winWidth,
this.winHeight
)
}
// 结束拖拽
endDrag() {
this.isDragging = false
}
// 更新高度
updateWindowHeight() {
const bodyHeight = document.body.scrollHeight
window.electron.ipcRenderer.send('update-window-height', bodyHeight)
}
// 动态监听高度
setupMutationObserver() {
const observer = new MutationObserver(this.updateWindowHeight.bind(this))
observer.observe(document.body, { childList: true, subtree: true, attributes: true })
this.updateWindowHeight()
}
}
new LyricsWindow()
</script>
</body>
</html>