mirror of
https://github.com/timeshiftsauce/CeruMusic.git
synced 2025-11-25 19:37:38 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9545f32c81 | ||
|
|
7945108243 | ||
|
|
6ce05d286f | ||
|
|
8209d021de | ||
|
|
a277cb7181 | ||
|
|
a9ad32e8ea | ||
|
|
ca3213d0b3 | ||
|
|
7c7455786e | ||
|
|
68fb9bcec5 | ||
|
|
54e2842b1b | ||
|
|
ce743e1b65 | ||
|
|
32c9fdbfeb | ||
|
|
9df236b2e0 | ||
|
|
0988c71282 | ||
|
|
60881f7f48 | ||
|
|
775f87aa86 | ||
|
|
b1c471f15c | ||
|
|
f7ecfa1fa9 | ||
|
|
d44be6022a | ||
|
|
0c512bccff | ||
|
|
b07cc2359a | ||
|
|
46756a8b09 | ||
|
|
deb73fa789 | ||
|
|
910ab1ff10 |
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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上运行
|
||||
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
BIN
docs/assets/image-20251003173109619.png
Normal file
BIN
docs/assets/image-20251003173109619.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
BIN
docs/assets/image-20251003173141699.png
Normal file
BIN
docs/assets/image-20251003173141699.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1020 KiB |
BIN
docs/assets/image-20251003173654569.png
Normal file
BIN
docs/assets/image-20251003173654569.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 870 KiB |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
15117
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
692
playlist-converter.html
Normal file
692
playlist-converter.html
Normal file
@@ -0,0 +1,692 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>歌单格式转换器</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: #f8f9ff;
|
||||
border: 3px dashed #4facfe;
|
||||
border-radius: 15px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-section:hover {
|
||||
border-color: #00f2fe;
|
||||
background: #f0f8ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upload-section.dragover {
|
||||
border-color: #00f2fe;
|
||||
background: #e8f4ff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 4em;
|
||||
color: #4facfe;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 1.3em;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upload-subtext {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
display: none;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 1.5em;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.song-count {
|
||||
background: #4facfe;
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
background: #f8f9ff;
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.song-item {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.song-item:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.song-number {
|
||||
background: #4facfe;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-right: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f1f3f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
display: none;
|
||||
background: #f8f9ff;
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-success {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.result-error {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result-message {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
border-radius: 25px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4facfe;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fed7d7;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
text-align: left;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #c53030;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎵 澜音歌单格式转换器</h1>
|
||||
<p>将洛雪音乐平台的歌单转换为澜音音乐的加密格式</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="upload-section" id="uploadSection">
|
||||
<div class="upload-icon">📁</div>
|
||||
<div class="upload-text">点击或拖拽洛雪歌单文件到此处</div>
|
||||
<div class="upload-subtext">支持 *.json 和 *.lxmc 格式的歌单文件</div>
|
||||
<input type="file" id="fileInput" class="file-input" accept=".json,.lxmc" />
|
||||
</div>
|
||||
|
||||
<div class="preview-section" id="previewSection">
|
||||
<div class="preview-header">
|
||||
<div class="preview-title">歌单预览</div>
|
||||
<div class="song-count" id="songCount">0 首歌曲</div>
|
||||
</div>
|
||||
<div class="preview-content" id="previewContent">
|
||||
<!-- 歌曲列表将在这里显示 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls" style="display: none">
|
||||
<button class="btn btn-primary" id="convertBtn">
|
||||
<span>🔐</span>
|
||||
转换并加密
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="resetBtn">
|
||||
<span>🔄</span>
|
||||
重新选择
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loadingSection">
|
||||
<div class="spinner"></div>
|
||||
<div>正在转换中...</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" id="resultSection">
|
||||
<div class="result-icon" id="resultIcon"></div>
|
||||
<div class="result-message" id="resultMessage"></div>
|
||||
<a href="#" class="download-link" id="downloadLink" style="display: none">
|
||||
<span>📥</span>
|
||||
下载澜音歌单
|
||||
</a>
|
||||
<div class="error-details" id="errorDetails" style="display: none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 全局变量
|
||||
let currentPlaylist = null
|
||||
let convertedPlaylist = null
|
||||
const SECRET_KEY = 'CeruMusic-PlaylistSecretKey'
|
||||
|
||||
// Gzip解压函数
|
||||
async function decompressGzip(arrayBuffer) {
|
||||
try {
|
||||
// 使用浏览器的 DecompressionStream API
|
||||
const stream = new Response(arrayBuffer).body.pipeThrough(new DecompressionStream('gzip'))
|
||||
const decompressed = await new Response(stream).arrayBuffer()
|
||||
return new TextDecoder().decode(decompressed)
|
||||
} catch (error) {
|
||||
throw new Error('Gzip解压失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// DOM元素
|
||||
const uploadSection = document.getElementById('uploadSection')
|
||||
const fileInput = document.getElementById('fileInput')
|
||||
const previewSection = document.getElementById('previewSection')
|
||||
const previewContent = document.getElementById('previewContent')
|
||||
const songCount = document.getElementById('songCount')
|
||||
const controls = document.getElementById('controls')
|
||||
const convertBtn = document.getElementById('convertBtn')
|
||||
const resetBtn = document.getElementById('resetBtn')
|
||||
const loadingSection = document.getElementById('loadingSection')
|
||||
const resultSection = document.getElementById('resultSection')
|
||||
const resultIcon = document.getElementById('resultIcon')
|
||||
const resultMessage = document.getElementById('resultMessage')
|
||||
const downloadLink = document.getElementById('downloadLink')
|
||||
const errorDetails = document.getElementById('errorDetails')
|
||||
|
||||
// 文件上传处理
|
||||
uploadSection.addEventListener('click', () => fileInput.click())
|
||||
|
||||
uploadSection.addEventListener('dragover', (e) => {
|
||||
e.preventDefault()
|
||||
uploadSection.classList.add('dragover')
|
||||
})
|
||||
|
||||
uploadSection.addEventListener('dragleave', () => {
|
||||
uploadSection.classList.remove('dragover')
|
||||
})
|
||||
|
||||
uploadSection.addEventListener('drop', (e) => {
|
||||
e.preventDefault()
|
||||
uploadSection.classList.remove('dragover')
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0])
|
||||
}
|
||||
})
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
handleFile(file)
|
||||
}
|
||||
})
|
||||
|
||||
// 按钮事件
|
||||
convertBtn.addEventListener('click', convertPlaylist)
|
||||
resetBtn.addEventListener('click', resetConverter)
|
||||
|
||||
// 处理文件
|
||||
function handleFile(file) {
|
||||
const fileName = file.name.toLowerCase()
|
||||
|
||||
if (fileName.endsWith('.json')) {
|
||||
// 处理JSON文件
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = JSON.parse(e.target.result)
|
||||
currentPlaylist = content
|
||||
displayPreview(content)
|
||||
} catch (error) {
|
||||
showError('JSON文件解析失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} else if (fileName.endsWith('.lxmc')) {
|
||||
// 处理LXMC文件(gzip压缩)
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const arrayBuffer = e.target.result
|
||||
const decompressed = await decompressGzip(arrayBuffer)
|
||||
const content = JSON.parse(decompressed)
|
||||
currentPlaylist = content
|
||||
displayPreview(content)
|
||||
} catch (error) {
|
||||
showError('LXMC文件处理失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
} else {
|
||||
showError('请选择JSON或LXMC格式的文件')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 显示预览
|
||||
function displayPreview(playlist) {
|
||||
previewContent.innerHTML = ''
|
||||
|
||||
const playlistData =
|
||||
playlist.type === 'playListPart_v2' && playlist.data ? playlist.data : playlist
|
||||
const songList = playlistData.list
|
||||
|
||||
if (!songList || !Array.isArray(songList)) {
|
||||
showError('无效的歌单格式:缺少歌曲列表')
|
||||
return
|
||||
}
|
||||
|
||||
// 显示歌曲列表
|
||||
songList.forEach((song, index) => {
|
||||
const songItem = document.createElement('div')
|
||||
songItem.className = 'song-item'
|
||||
songItem.innerHTML = `
|
||||
<div class="song-number">${index + 1}</div>
|
||||
<div class="song-info">
|
||||
<div class="song-name">${song.name || '未知歌曲'}</div>
|
||||
<div class="song-artist">${song.singer || '未知艺术家'} • ${song.interval || '0:00'}</div>
|
||||
</div>
|
||||
`
|
||||
previewContent.appendChild(songItem)
|
||||
})
|
||||
|
||||
songCount.textContent = `${songList.length} 首歌曲`
|
||||
previewSection.style.display = 'block'
|
||||
controls.style.display = 'flex'
|
||||
uploadSection.style.display = 'none'
|
||||
}
|
||||
|
||||
// 转换歌单
|
||||
function convertPlaylist() {
|
||||
if (!currentPlaylist) return
|
||||
|
||||
showLoading()
|
||||
|
||||
try {
|
||||
// 转换格式
|
||||
const convertedSongs = convertToTargetFormat(currentPlaylist)
|
||||
|
||||
// 加密
|
||||
const encryptedData = encryptPlaylist(convertedSongs)
|
||||
|
||||
convertedPlaylist = {
|
||||
original: currentPlaylist,
|
||||
converted: convertedSongs,
|
||||
encrypted: encryptedData
|
||||
}
|
||||
|
||||
showSuccess(convertedPlaylist)
|
||||
} catch (error) {
|
||||
showError('转换失败: ' + error.message, error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为目标格式
|
||||
function convertToTargetFormat(originalPlaylist) {
|
||||
if (originalPlaylist.type === 'playListPart_v2' && originalPlaylist.data) {
|
||||
originalPlaylist = originalPlaylist.data
|
||||
}
|
||||
if (!originalPlaylist.list || !Array.isArray(originalPlaylist.list)) {
|
||||
throw new Error('原始歌单格式无效')
|
||||
}
|
||||
|
||||
return originalPlaylist.list.map((song) => {
|
||||
// 从meta中提取额外信息
|
||||
const meta = song.meta || {}
|
||||
|
||||
return {
|
||||
songmid: meta.hash || meta.songId,
|
||||
singer: song.singer || '未知艺术家',
|
||||
name: song.name || '未知歌曲',
|
||||
albumName: meta.albumName || '未知专辑',
|
||||
albumId: meta.albumId,
|
||||
source: song.source || 'unknown',
|
||||
interval: song.interval || '0:00',
|
||||
img: meta.picUrl || '',
|
||||
lrc: null,
|
||||
types: meta.qualitys,
|
||||
_types: meta._qualitys,
|
||||
typeUrl: {},
|
||||
url: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 转换音质类型
|
||||
function convertQualityTypes(qualities) {
|
||||
const types = {}
|
||||
if (!qualities || !Array.isArray(qualities)) {
|
||||
types['128k'] = { size: '未知' }
|
||||
return types
|
||||
}
|
||||
|
||||
qualities.forEach((quality) => {
|
||||
if (typeof quality.type !== 'string') return
|
||||
const q = quality.type.toLowerCase()
|
||||
if (q.includes('flac')) {
|
||||
if (q.includes('24')) {
|
||||
types['flac24bit'] = { size: '未知' }
|
||||
} else {
|
||||
types['flac'] = { size: '未知' }
|
||||
}
|
||||
} else if (q.includes('320')) {
|
||||
types['320k'] = { size: '未知' }
|
||||
} else if (q.includes('128')) {
|
||||
types['128k'] = { size: '未知' }
|
||||
} else {
|
||||
types[q] = { size: '未知' }
|
||||
}
|
||||
})
|
||||
|
||||
return Object.keys(types).length > 0 ? types : { '128k': { size: '未知' } }
|
||||
}
|
||||
|
||||
// 生成随机ID
|
||||
function generateRandomId() {
|
||||
return Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
// 加密歌单
|
||||
function encryptPlaylist(songs) {
|
||||
const dataToEncrypt = JSON.stringify(songs)
|
||||
return CryptoJS.AES.encrypt(dataToEncrypt, SECRET_KEY).toString()
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
function showLoading() {
|
||||
loadingSection.style.display = 'block'
|
||||
controls.style.display = 'none'
|
||||
resultSection.style.display = 'none'
|
||||
}
|
||||
|
||||
// 显示成功结果
|
||||
function showSuccess(result) {
|
||||
loadingSection.style.display = 'none'
|
||||
resultSection.style.display = 'block'
|
||||
|
||||
resultIcon.className = 'result-icon result-success'
|
||||
resultIcon.textContent = '✅'
|
||||
resultMessage.textContent = '歌单转换成功!'
|
||||
|
||||
// 创建下载链接
|
||||
const encryptedData = result.encrypted
|
||||
const blob = new Blob([encryptedData], {
|
||||
type: 'application/octet-stream'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
downloadLink.href = url
|
||||
downloadLink.download = `cerumusic-playlist-${new Date().toISOString().slice(0, 10)}.cpl`
|
||||
downloadLink.style.display = 'inline-block'
|
||||
|
||||
// 清理之前的URL
|
||||
if (downloadLink.dataset.url) {
|
||||
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||
}
|
||||
downloadLink.dataset.url = url
|
||||
}
|
||||
|
||||
// 显示错误信息
|
||||
function showError(message, details = null) {
|
||||
loadingSection.style.display = 'none'
|
||||
resultSection.style.display = 'block'
|
||||
|
||||
resultIcon.className = 'result-icon result-error'
|
||||
resultIcon.textContent = '❌'
|
||||
resultMessage.textContent = message
|
||||
|
||||
if (details) {
|
||||
errorDetails.textContent = details
|
||||
errorDetails.style.display = 'block'
|
||||
}
|
||||
|
||||
downloadLink.style.display = 'none'
|
||||
}
|
||||
|
||||
// 重置转换器
|
||||
function resetConverter() {
|
||||
currentPlaylist = null
|
||||
convertedPlaylist = null
|
||||
|
||||
uploadSection.style.display = 'block'
|
||||
previewSection.style.display = 'none'
|
||||
controls.style.display = 'none'
|
||||
loadingSection.style.display = 'none'
|
||||
resultSection.style.display = 'none'
|
||||
|
||||
fileInput.value = ''
|
||||
previewContent.innerHTML = ''
|
||||
errorDetails.style.display = 'none'
|
||||
|
||||
// 清理下载链接
|
||||
if (downloadLink.dataset.url) {
|
||||
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||
delete downloadLink.dataset.url
|
||||
}
|
||||
}
|
||||
|
||||
// 页面卸载时清理资源
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (downloadLink.dataset.url) {
|
||||
URL.revokeObjectURL(downloadLink.dataset.url)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
10
src/common/types/config.ts
Normal file
10
src/common/types/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface lyricConfig {
|
||||
fontSize: number
|
||||
mainColor: string
|
||||
shadowColor: string
|
||||
// 窗口位置
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
9
src/common/types/localSongs.ts
Normal file
9
src/common/types/localSongs.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default interface localList {
|
||||
singer: string
|
||||
name: string
|
||||
albumName: string
|
||||
interval: string
|
||||
duration: string
|
||||
img: string
|
||||
lrc: null | string
|
||||
}
|
||||
@@ -11,5 +11,6 @@ export default interface PlayList {
|
||||
lrc: null | string
|
||||
types: string[]
|
||||
_types: Record<string, any>
|
||||
typeUrl: Record<string, any>
|
||||
typeUrl?: Record<string, any>
|
||||
url?: string
|
||||
}
|
||||
|
||||
92
src/common/utils/quality.ts
Normal file
92
src/common/utils/quality.ts
Normal 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
107
src/main/events/index.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
378
src/main/events/localMusic.ts
Normal file
378
src/main/events/localMusic.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { ipcMain, dialog, app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'node:path'
|
||||
import crypto from 'crypto'
|
||||
import { localMusicIndexService } from '../services/LocalMusicIndex'
|
||||
// remove static import to avoid runtime failures if native module is missing
|
||||
|
||||
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.wav', '.aac', '.m4a', '.ogg', '.wma'])
|
||||
|
||||
function genId(input: string) {
|
||||
return crypto.createHash('md5').update(input).digest('hex')
|
||||
}
|
||||
|
||||
async function walkDir(dir: string, results: string[]) {
|
||||
try {
|
||||
const items = await fsp.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const full = path.join(dir, item.name)
|
||||
if (item.isDirectory()) {
|
||||
await walkDir(full, results)
|
||||
} else {
|
||||
const ext = path.extname(item.name).toLowerCase()
|
||||
if (AUDIO_EXTS.has(ext)) results.push(full)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function readTags(filePath: string) {
|
||||
try {
|
||||
const taglib = require('node-taglib-sharp')
|
||||
const f = taglib.File.createFromPath(filePath)
|
||||
const tag = f.tag
|
||||
const title = tag.title || ''
|
||||
const album = tag.album || ''
|
||||
const performers = Array.isArray(tag.performers) ? tag.performers : []
|
||||
let img = ''
|
||||
if (Array.isArray(tag.pictures) && tag.pictures.length > 0) {
|
||||
try {
|
||||
const buf = tag.pictures[0].data
|
||||
const mime = tag.pictures[0].mimeType || 'image/jpeg'
|
||||
img = `data:${mime};base64,${Buffer.from(buf).toString('base64')}`
|
||||
} catch {}
|
||||
}
|
||||
let lrc: string | null = null
|
||||
try {
|
||||
const raw = tag.lyrics || ''
|
||||
if (raw && typeof raw === 'string') {
|
||||
lrc = normalizeLyricsToLrc(raw)
|
||||
}
|
||||
} catch {}
|
||||
f.dispose()
|
||||
return { title, album, performers, img, lrc }
|
||||
} catch {
|
||||
return { title: '', album: '', performers: [], img: '', lrc: null }
|
||||
}
|
||||
}
|
||||
|
||||
// 将两种逐字/行内时间歌词统一转换为标准LRC(仅保留行时间标签)
|
||||
function normalizeLyricsToLrc(input: string): string {
|
||||
const lines = String(input).split('\n')
|
||||
const msFormat = (timeMs: number) => {
|
||||
if (!Number.isFinite(timeMs)) return ''
|
||||
const m = Math.floor(timeMs / 60000)
|
||||
const s = Math.floor((timeMs % 60000) / 1000)
|
||||
const ms = Math.floor(timeMs % 1000)
|
||||
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}]`
|
||||
}
|
||||
const out: string[] = []
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
const off = /^\[offset:[+-]?\d+\]$/i.exec(line.trim())
|
||||
if (off) {
|
||||
out.push(line.trim())
|
||||
continue
|
||||
}
|
||||
const mNew = /^\[(\d+),(\d+)\](.*)$/.exec(line)
|
||||
if (mNew) {
|
||||
const startMs = parseInt(mNew[1])
|
||||
let text = mNew[3] || ''
|
||||
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
|
||||
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
|
||||
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mNew[3] || '')) {
|
||||
text = text.replace(/[()]/g, '')
|
||||
}
|
||||
text = text.replace(/\s+/g, ' ').trim()
|
||||
const tag = msFormat(startMs)
|
||||
out.push(`${tag}${text}`)
|
||||
continue
|
||||
}
|
||||
const mOld = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
|
||||
if (mOld) {
|
||||
let text = mOld[2] || ''
|
||||
text = text.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
|
||||
text = text.replace(/<\d{2}:\d{2}\.\d{3}>/g, '')
|
||||
if (/(<\d{2}:\d{2}\.\d{3}>)|(\(\d+,\d+(?:,\d+)?\))/.test(mOld[2] || '')) {
|
||||
text = text.replace(/[()]/g, '')
|
||||
}
|
||||
text = text.replace(/\s+/g, ' ').trim()
|
||||
const tag = `[${mOld[1]}]`
|
||||
out.push(`${tag}${text}`)
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
// function timeToMs(s: string): number {
|
||||
// const m = /(\d{2}):(\d{2})\.(\d{3})/.exec(s)
|
||||
// if (!m) return NaN
|
||||
// return parseInt(m[1]) * 60000 + parseInt(m[2]) * 1000 + parseInt(m[3])
|
||||
// }
|
||||
|
||||
// function normalizeLyricsToCrLyric(input: string): string {
|
||||
// const raw = String(input).replace(/\r/g, '')
|
||||
// const lines = raw.split('\n')
|
||||
// let offset = 0
|
||||
// const res: string[] = []
|
||||
// for (let i = 0; i < lines.length; i++) {
|
||||
// const line = lines[i]
|
||||
// if (!line.trim()) {
|
||||
// res.push(line)
|
||||
// continue
|
||||
// }
|
||||
// const off = /^\[offset:([+-]?\d+)\]$/i.exec(line.trim())
|
||||
// if (off) {
|
||||
// offset = parseInt(off[1]) || 0
|
||||
// res.push(line)
|
||||
// continue
|
||||
// }
|
||||
// const yrcLike = /\[\d+,\d+\]/.test(line) && /\(\d+,\d+,\d+\)/.test(line)
|
||||
// if (yrcLike) {
|
||||
// res.push(line)
|
||||
// continue
|
||||
// }
|
||||
// const mLine = /^\[(\d{2}:\d{2}\.\d{3})\](.*)$/.exec(line)
|
||||
// if (!mLine) {
|
||||
// res.push(line)
|
||||
// continue
|
||||
// }
|
||||
// const lineStart = timeToMs(mLine[1]) + offset
|
||||
// let rest = mLine[2]
|
||||
// rest = rest.replace(/\(\d+,\d+(?:,\d+)?\)/g, '')
|
||||
// const segs: { start: number; text: string }[] = []
|
||||
// const re = /<(\d{2}:\d{2}\.\d{3})>([^<]*)/g
|
||||
// let m: RegExpExecArray | null
|
||||
// while ((m = re.exec(rest))) {
|
||||
// const start = timeToMs(m[1]) + offset
|
||||
// const text = m[2] || ''
|
||||
// if (text) segs.push({ start, text })
|
||||
// }
|
||||
// if (segs.length === 0) {
|
||||
// res.push(line)
|
||||
// continue
|
||||
// }
|
||||
// let nextLineStart: number | null = null
|
||||
// for (let j = i + 1; j < lines.length; j++) {
|
||||
// const ml = /^\[(\d{2}:\d{2}\.\d{3})\]/.exec(lines[j])
|
||||
// if (ml) {
|
||||
// nextLineStart = timeToMs(ml[1]) + offset
|
||||
// break
|
||||
// }
|
||||
// const skip = lines[j].trim()
|
||||
// if (!skip || /^\[offset:/.test(skip)) continue
|
||||
// break
|
||||
// }
|
||||
// const tokens: string[] = []
|
||||
// for (let k = 0; k < segs.length; k++) {
|
||||
// const cur = segs[k]
|
||||
// const nextStart =
|
||||
// k < segs.length - 1 ? segs[k + 1].start : (nextLineStart ?? cur.start + 1000)
|
||||
// const span = Math.max(1, nextStart - cur.start)
|
||||
// const chars = Array.from(cur.text).filter((ch) => !/\s/.test(ch))
|
||||
// if (chars.length <= 1) {
|
||||
// if (chars.length === 1) tokens.push(`(${cur.start},${span},0)` + chars[0])
|
||||
// } else {
|
||||
// const per = Math.max(1, Math.floor(span / chars.length))
|
||||
// for (let c = 0; c < chars.length; c++) {
|
||||
// const cs = cur.start + c * per
|
||||
// const cd = c === chars.length - 1 ? Math.max(1, nextStart - cs) : per
|
||||
// tokens.push(`(${cs},${cd},0)` + chars[c])
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const lineEnd =
|
||||
// nextLineStart ??
|
||||
// segs[segs.length - 1].start +
|
||||
// Math.max(
|
||||
// 1,
|
||||
// (nextLineStart ?? segs[segs.length - 1].start + 1000) - segs[segs.length - 1].start
|
||||
// )
|
||||
// const ld = Math.max(0, lineEnd - lineStart)
|
||||
// res.push(`[${lineStart},${ld}]` + tokens.join(' '))
|
||||
// }
|
||||
// return res.join('\n')
|
||||
// }
|
||||
|
||||
ipcMain.handle('local-music:select-dirs', async () => {
|
||||
const res = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
|
||||
if (res.canceled) return []
|
||||
return res.filePaths
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:scan', async (_e, dirs: string[]) => {
|
||||
if (!Array.isArray(dirs) || dirs.length === 0) {
|
||||
return []
|
||||
}
|
||||
const existsDirs = dirs.filter((d) => {
|
||||
try {
|
||||
return fs.existsSync(d)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
const files: string[] = []
|
||||
try {
|
||||
for (const d of existsDirs) await walkDir(d, files)
|
||||
const list = files.map((p) => {
|
||||
let tags = {
|
||||
title: '',
|
||||
album: '',
|
||||
performers: [] as string[],
|
||||
img: '',
|
||||
lrc: null as null | string
|
||||
}
|
||||
try {
|
||||
tags = readTags(p)
|
||||
} catch {}
|
||||
const base = path.basename(p)
|
||||
const noExt = base.replace(path.extname(base), '')
|
||||
let name = tags.title || ''
|
||||
let singer = ''
|
||||
if (!name) {
|
||||
const segs = noExt
|
||||
.split(/[-_]|\s{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (segs.length >= 2) {
|
||||
singer = segs[0]
|
||||
name = segs.slice(1).join(' ')
|
||||
} else {
|
||||
name = noExt
|
||||
}
|
||||
} else {
|
||||
singer =
|
||||
Array.isArray(tags.performers) && tags.performers.length > 0 ? tags.performers[0] : ''
|
||||
}
|
||||
const songmid = genId(p)
|
||||
const item = {
|
||||
songmid,
|
||||
singer: singer || '未知艺术家',
|
||||
name: name || '未知曲目',
|
||||
albumName: tags.album || '未知专辑',
|
||||
albumId: 0,
|
||||
source: 'local',
|
||||
interval: '',
|
||||
img: tags.img || '',
|
||||
lrc: tags.lrc || null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {},
|
||||
url: 'file://' + p,
|
||||
path: p
|
||||
}
|
||||
return item
|
||||
})
|
||||
await localMusicIndexService.setDirs(existsDirs)
|
||||
await localMusicIndexService.upsertSongs(list)
|
||||
try {
|
||||
return JSON.stringify(list)
|
||||
} catch {
|
||||
return '[]'
|
||||
}
|
||||
} catch (e) {
|
||||
return '[]'
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:write-tags', async (_e, payload: any) => {
|
||||
const { filePath, songInfo, tagWriteOptions } = payload || {}
|
||||
if (!filePath || !fs.existsSync(filePath)) return { success: false, message: '文件不存在' }
|
||||
try {
|
||||
const taglib = require('node-taglib-sharp')
|
||||
const songFile = taglib.File.createFromPath(filePath)
|
||||
taglib.Id3v2Settings.forceDefaultVersion = true
|
||||
taglib.Id3v2Settings.defaultVersion = 3
|
||||
songFile.tag.title = songInfo?.name || '未知曲目'
|
||||
songFile.tag.album = songInfo?.albumName || '未知专辑'
|
||||
const artists = songInfo?.singer ? [songInfo.singer] : ['未知艺术家']
|
||||
songFile.tag.performers = artists
|
||||
songFile.tag.albumArtists = artists
|
||||
if (tagWriteOptions?.lyrics && songInfo?.lrc) {
|
||||
const normalized = normalizeLyricsToLrc(songInfo.lrc)
|
||||
songFile.tag.lyrics = normalized
|
||||
}
|
||||
if (tagWriteOptions?.cover && songInfo?.img) {
|
||||
try {
|
||||
if (songInfo.img.startsWith('data:')) {
|
||||
const m = songInfo.img.match(/^data:(.*?);base64,(.*)$/)
|
||||
if (m) {
|
||||
// const mime = m[1]
|
||||
const buf = Buffer.from(m[2], 'base64')
|
||||
const tmp = path.join(path.dirname(filePath), genId(filePath) + '.cover')
|
||||
await fsp.writeFile(tmp, buf)
|
||||
const pic = taglib.Picture.fromPath(tmp)
|
||||
songFile.tag.pictures = [pic]
|
||||
try {
|
||||
await fsp.unlink(tmp)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
songFile.save()
|
||||
songFile.dispose()
|
||||
const songmid = genId(filePath)
|
||||
await localMusicIndexService.upsertSong({
|
||||
songmid,
|
||||
singer: songInfo?.singer || '未知艺术家',
|
||||
name: songInfo?.name || '未知曲目',
|
||||
albumName: songInfo?.albumName || '未知专辑',
|
||||
albumId: 0,
|
||||
source: 'local',
|
||||
interval: '',
|
||||
img: songInfo?.img || '',
|
||||
lrc: songInfo?.lrc || null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {},
|
||||
url: 'file://' + filePath,
|
||||
path: filePath
|
||||
})
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e?.message || '写入失败' }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:get-dirs', async () => {
|
||||
return localMusicIndexService.getDirs()
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:set-dirs', async (_e, dirs: string[]) => {
|
||||
await localMusicIndexService.setDirs(Array.isArray(dirs) ? dirs : [])
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:get-list', async () => {
|
||||
return localMusicIndexService.getAllSongs()
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:get-url', async (_e, id: string | number) => {
|
||||
const u = localMusicIndexService.getUrlById(id)
|
||||
if (!u) return { error: '未找到本地文件' }
|
||||
return u
|
||||
})
|
||||
|
||||
ipcMain.handle('local-music:clear-index', async () => {
|
||||
try {
|
||||
const fn = (localMusicIndexService as any).clearSongs
|
||||
if (typeof fn === 'function') {
|
||||
await fn.call(localMusicIndexService)
|
||||
return { success: true }
|
||||
}
|
||||
const dirs = localMusicIndexService.getDirs()
|
||||
const file = require('node:path').join(app.getPath('userData'), 'local-music-index.json')
|
||||
const data = { songs: {}, dirs: Array.isArray(dirs) ? dirs : [], updatedAt: Date.now() }
|
||||
await require('fs/promises').writeFile(file, JSON.stringify(data, null, 2))
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, message: e?.message || '清空失败' }
|
||||
}
|
||||
})
|
||||
176
src/main/events/lyric.ts
Normal file
176
src/main/events/lyric.ts
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
80
src/main/events/plugins.ts
Normal file
80
src/main/events/plugins.ts
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
47
src/main/logger/index.ts
Normal 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')
|
||||
@@ -60,6 +60,7 @@ export class ConfigManager {
|
||||
// 设置配置项
|
||||
public set<T>(key: string, value: T): void {
|
||||
this.config[key] = value
|
||||
this.saveConfig()
|
||||
}
|
||||
|
||||
// 删除配置项
|
||||
|
||||
113
src/main/services/LocalMusicIndex.ts
Normal file
113
src/main/services/LocalMusicIndex.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
// import { configManager } from './ConfigManager'
|
||||
|
||||
export interface MusicItem {
|
||||
hash?: string
|
||||
singer: string
|
||||
name: string
|
||||
albumName: string
|
||||
albumId: number
|
||||
source: string
|
||||
interval: string
|
||||
songmid: number | string
|
||||
img: string
|
||||
lrc: null | string
|
||||
types: string[]
|
||||
_types: Record<string, any>
|
||||
typeUrl: Record<string, any>
|
||||
url?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
type IndexSchema = {
|
||||
songs: Record<string, MusicItem>
|
||||
dirs: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
function md5(input: string) {
|
||||
return crypto.createHash('md5').update(input).digest('hex')
|
||||
}
|
||||
|
||||
export class LocalMusicIndexService {
|
||||
private indexFile: string
|
||||
private data: IndexSchema = { songs: {}, dirs: [], updatedAt: Date.now() }
|
||||
|
||||
constructor() {
|
||||
const userData = require('electron').app.getPath('userData')
|
||||
this.indexFile = path.join(userData, 'local-music-index.json')
|
||||
this.load()
|
||||
}
|
||||
|
||||
private load() {
|
||||
try {
|
||||
if (fs.existsSync(this.indexFile)) {
|
||||
const raw = fs.readFileSync(this.indexFile, 'utf-8')
|
||||
const obj = JSON.parse(raw)
|
||||
if (obj && typeof obj === 'object') this.data = obj as IndexSchema
|
||||
}
|
||||
} catch {
|
||||
this.data = { songs: {}, dirs: [], updatedAt: Date.now() }
|
||||
}
|
||||
}
|
||||
|
||||
private async save() {
|
||||
try {
|
||||
const dir = path.dirname(this.indexFile)
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
await fsp.writeFile(this.indexFile, JSON.stringify(this.data, null, 2))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
getDirs() {
|
||||
return [...(this.data.dirs || [])]
|
||||
}
|
||||
|
||||
async setDirs(dirs: string[]) {
|
||||
this.data.dirs = Array.from(new Set(dirs.filter(Boolean)))
|
||||
this.data.updatedAt = Date.now()
|
||||
await this.save()
|
||||
}
|
||||
|
||||
getAllSongs(): MusicItem[] {
|
||||
return Object.values(this.data.songs)
|
||||
}
|
||||
|
||||
getSongById(id: string | number): MusicItem | null {
|
||||
const key = String(id)
|
||||
return this.data.songs[key] || null
|
||||
}
|
||||
|
||||
getUrlById(id: string | number): string | null {
|
||||
const s = this.getSongById(id)
|
||||
if (!s) return null
|
||||
if (s.url && typeof s.url === 'string') return s.url
|
||||
if (s.path && typeof s.path === 'string') return 'file://' + s.path
|
||||
return null
|
||||
}
|
||||
|
||||
async upsertSong(item: MusicItem) {
|
||||
const key = String(item.songmid ?? md5(String(item.path || '')))
|
||||
item.songmid = key
|
||||
item.hash = md5(`${item.name}-${item.singer}-${item.source}`)
|
||||
this.data.songs[key] = item
|
||||
this.data.updatedAt = Date.now()
|
||||
await this.save()
|
||||
}
|
||||
|
||||
async upsertSongs(items: MusicItem[]) {
|
||||
for (const it of items) {
|
||||
const key = String(it.songmid ?? md5(String(it.path || '')))
|
||||
it.songmid = key
|
||||
it.hash = md5(`${it.name}-${it.singer}-${it.source}`)
|
||||
this.data.songs[key] = it
|
||||
}
|
||||
this.data.updatedAt = Date.now()
|
||||
await this.save()
|
||||
}
|
||||
}
|
||||
|
||||
export const localMusicIndexService = new LocalMusicIndexService()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
190
src/main/utils/musicSdk/kg/quality_detail.js
Normal file
190
src/main/utils/musicSdk/kg/quality_detail.js
Normal 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: {},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,15 +808,15 @@ 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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,15 +527,15 @@ 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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
86
src/main/utils/musicSdk/tx/quality_detail.js
Normal file
86
src/main/utils/musicSdk/tx/quality_detail.js
Normal 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
|
||||
}
|
||||
@@ -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¬ice=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,15 +321,15 @@ 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
|
||||
|
||||
@@ -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,7 +39,7 @@ 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)
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
91
src/main/utils/musicSdk/wy/quality_detail.js
Normal file
91
src/main/utils/musicSdk/wy/quality_detail.js
Normal 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
|
||||
}
|
||||
@@ -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
45
src/main/windows/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
95
src/main/windows/lyric-window.ts
Normal file
95
src/main/windows/lyric-window.ts
Normal 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()
|
||||
12
src/preload/index.d.ts
vendored
12
src/preload/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
144
src/renderer/auto-imports.d.ts
vendored
144
src/renderer/auto-imports.d.ts
vendored
@@ -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')
|
||||
}
|
||||
|
||||
9
src/renderer/components.d.ts
vendored
9
src/renderer/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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()
|
||||
|
||||
3
src/renderer/src/assets/icons/lyricOpen.svg
Normal file
3
src/renderer/src/assets/icons/lyricOpen.svg
Normal 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 |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 范围)
|
||||
// 假设采样率为 44100Hz,fftSize 为 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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
219
src/renderer/src/components/Settings/DesktopLyricStyle.vue
Normal file
219
src/renderer/src/components/Settings/DesktopLyricStyle.vue
Normal 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>
|
||||
@@ -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子元素能够正确计算高度 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,8 @@ export const ControlAudioStore = defineStore(
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 80,
|
||||
url: ''
|
||||
url: '',
|
||||
eventInit: false
|
||||
})
|
||||
|
||||
// -------------------------------------------发布订阅逻辑------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ControlAudioState = {
|
||||
duration: number
|
||||
volume: number
|
||||
url: string
|
||||
eventInit: boolean
|
||||
}
|
||||
|
||||
export type SongList = playList
|
||||
|
||||
@@ -1,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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal file
407
src/renderer/src/utils/audio/globaPlayList.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { ref, toRaw } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { PlayMode, type SongList } from '@renderer/types/audio'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import defaultCoverImg from '/default-cover.png'
|
||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||
import {
|
||||
getSongRealUrl,
|
||||
initPlaylistEventListeners,
|
||||
destroyPlaylistEventListeners
|
||||
} from '@renderer/utils/playlist/playlistManager'
|
||||
|
||||
const controlAudio = ControlAudioStore()
|
||||
const localUserStore = LocalUserDetailStore()
|
||||
const { Audio } = storeToRefs(controlAudio)
|
||||
const { list, userInfo } = storeToRefs(localUserStore)
|
||||
|
||||
const songInfo = ref<Omit<SongList, 'songmid'> & { songmid: null | number | string }>({
|
||||
songmid: null,
|
||||
hash: '',
|
||||
name: '欢迎使用CeruMusic 🎉',
|
||||
singer: '可以配置音源插件来播放你的歌曲',
|
||||
albumName: '',
|
||||
albumId: '0',
|
||||
source: '',
|
||||
interval: '00:00',
|
||||
img: '',
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {}
|
||||
})
|
||||
|
||||
let pendingRestorePosition = 0
|
||||
let pendingRestoreSongId: number | string | null = null
|
||||
|
||||
const waitForAudioReady = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = Audio.value.audio
|
||||
if (!audio) {
|
||||
reject(new Error('音频元素未初始化'))
|
||||
return
|
||||
}
|
||||
if (audio.readyState >= 3) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载超时'))
|
||||
}, 10000)
|
||||
const onCanPlay = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
resolve()
|
||||
}
|
||||
const onError = () => {
|
||||
clearTimeout(timeout)
|
||||
audio.removeEventListener('canplay', onCanPlay)
|
||||
audio.removeEventListener('error', onError)
|
||||
reject(new Error('音频加载失败'))
|
||||
}
|
||||
audio.addEventListener('canplay', onCanPlay, { once: true })
|
||||
audio.addEventListener('error', onError, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const setUrl = controlAudio.setUrl
|
||||
const start = controlAudio.start
|
||||
const stop = controlAudio.stop
|
||||
const setCurrentTime = controlAudio.setCurrentTime
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!Audio.value.url) {
|
||||
if (list.value.length > 0) {
|
||||
await playSong(list.value[0])
|
||||
} else {
|
||||
MessagePlugin.warning('播放列表为空,请先添加歌曲')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pendingRestorePosition > 0 && pendingRestoreSongId === userInfo.value.lastPlaySongId) {
|
||||
await waitForAudioReady()
|
||||
setCurrentTime(pendingRestorePosition)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = pendingRestorePosition
|
||||
}
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
const startResult = start()
|
||||
if (startResult && typeof (startResult as any).then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
mediaSessionController.updatePlaybackState('playing')
|
||||
} catch (error) {
|
||||
MessagePlugin.error('播放失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = async () => {
|
||||
const a = Audio.value.audio
|
||||
if (Audio.value.url && a && !a.paused) {
|
||||
const stopResult = stop()
|
||||
if (stopResult && typeof (stopResult as any).then === 'function') {
|
||||
await stopResult
|
||||
}
|
||||
mediaSessionController.updatePlaybackState('paused')
|
||||
} else if (Audio.value.url) {
|
||||
mediaSessionController.updatePlaybackState('paused')
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlayPause = async () => {
|
||||
const a = Audio.value.audio
|
||||
const isActuallyPlaying = a ? !a.paused : Audio.value.isPlay
|
||||
if (isActuallyPlaying) {
|
||||
await handlePause()
|
||||
} else {
|
||||
await handlePlay()
|
||||
}
|
||||
}
|
||||
|
||||
const playSong = async (song: SongList) => {
|
||||
try {
|
||||
isLoadingSong.value = true
|
||||
const isHistoryPlay =
|
||||
song.songmid === userInfo.value.lastPlaySongId &&
|
||||
userInfo.value.currentTime !== undefined &&
|
||||
userInfo.value.currentTime > 0
|
||||
if (isHistoryPlay && userInfo.value.currentTime !== undefined) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = song.songmid
|
||||
userInfo.value.currentTime = 0
|
||||
} else {
|
||||
pendingRestorePosition = 0
|
||||
pendingRestoreSongId = null
|
||||
}
|
||||
if (Audio.value.isPlay && Audio.value.audio) {
|
||||
Audio.value.isPlay = false
|
||||
Audio.value.audio.pause()
|
||||
Audio.value.audio.volume = Audio.value.volume / 100
|
||||
}
|
||||
songInfo.value.name = song.name
|
||||
songInfo.value.singer = song.singer
|
||||
songInfo.value.albumName = song.albumName
|
||||
songInfo.value.img = song.img
|
||||
userInfo.value.lastPlaySongId = song.songmid
|
||||
mediaSessionController.updateMetadata({
|
||||
title: song.name,
|
||||
artist: song.singer,
|
||||
album: song.albumName || '未知专辑',
|
||||
artworkUrl: song.img || defaultCoverImg
|
||||
})
|
||||
let urlToPlay = ''
|
||||
try {
|
||||
urlToPlay = await getSongRealUrl(toRaw(song))
|
||||
} catch (error: any) {
|
||||
isLoadingSong.value = false
|
||||
tryAutoNext('获取歌曲 URL 失败')
|
||||
return
|
||||
}
|
||||
if (Audio.value.audio) {
|
||||
const a = Audio.value.audio
|
||||
try {
|
||||
a.pause()
|
||||
} catch {}
|
||||
a.removeAttribute('src')
|
||||
a.load()
|
||||
}
|
||||
setUrl(urlToPlay)
|
||||
await waitForAudioReady()
|
||||
songInfo.value = { ...song }
|
||||
isLoadingSong.value = false
|
||||
start()
|
||||
.catch(async () => {
|
||||
tryAutoNext('启动播放失败')
|
||||
})
|
||||
.then(() => {
|
||||
autoNextCount.value = 0
|
||||
})
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.addEventListener(
|
||||
'playing',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
Audio.value.audio.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
isLoadingSong.value = false
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
tryAutoNext('播放歌曲失败')
|
||||
isLoadingSong.value = false
|
||||
} finally {
|
||||
isLoadingSong.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tryAutoNext = (reason: string) => {
|
||||
const limit = getAutoNextLimit()
|
||||
MessagePlugin.error(`自动跳过当前歌曲:原因:${reason}`)
|
||||
if (autoNextCount.value >= limit && autoNextCount.value > 2) {
|
||||
MessagePlugin.error(
|
||||
`自动下一首失败:超过当前列表30%限制(${autoNextCount.value}/${limit})。原因:${reason}`
|
||||
)
|
||||
return
|
||||
}
|
||||
autoNextCount.value++
|
||||
playNext()
|
||||
}
|
||||
|
||||
const playMode = ref(userInfo.value.playMode || PlayMode.SEQUENCE)
|
||||
const isLoadingSong = ref(false)
|
||||
const autoNextCount = ref(0)
|
||||
const getAutoNextLimit = () => Math.max(1, Math.floor(list.value.length * 0.3))
|
||||
|
||||
const updatePlayMode = () => {
|
||||
const modes = [PlayMode.SEQUENCE, PlayMode.RANDOM, PlayMode.SINGLE]
|
||||
const currentIndex = modes.indexOf(playMode.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
playMode.value = modes[nextIndex]
|
||||
userInfo.value.playMode = playMode.value
|
||||
}
|
||||
|
||||
const playPrevious = async () => {
|
||||
if (list.value.length === 0) return
|
||||
try {
|
||||
const currentIndex = list.value.findIndex(
|
||||
(song) => song.songmid === userInfo.value.lastPlaySongId
|
||||
)
|
||||
let prevIndex
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
prevIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
prevIndex = currentIndex <= 0 ? list.value.length - 1 : currentIndex - 1
|
||||
}
|
||||
if (prevIndex >= 0 && prevIndex < list.value.length) {
|
||||
await playSong(list.value[prevIndex])
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.error('播放上一首失败')
|
||||
}
|
||||
}
|
||||
|
||||
const playNext = async () => {
|
||||
if (list.value.length === 0) return
|
||||
try {
|
||||
if (playMode.value === PlayMode.SINGLE && userInfo.value.lastPlaySongId) {
|
||||
const currentSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
|
||||
if (currentSong) {
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = 0
|
||||
}
|
||||
const startResult = start()
|
||||
if (startResult && typeof (startResult as any).then === 'function') {
|
||||
await startResult
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
const currentIndex = list.value.findIndex(
|
||||
(song) => song.songmid === userInfo.value.lastPlaySongId
|
||||
)
|
||||
let nextIndex
|
||||
if (playMode.value === PlayMode.RANDOM) {
|
||||
nextIndex = Math.floor(Math.random() * list.value.length)
|
||||
} else {
|
||||
nextIndex = (currentIndex + 1) % list.value.length
|
||||
}
|
||||
if (nextIndex >= 0 && nextIndex < list.value.length) {
|
||||
await playSong(list.value[nextIndex])
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.error('播放下一首失败')
|
||||
}
|
||||
}
|
||||
|
||||
const setVolume = (v: number) => controlAudio.setVolume(v)
|
||||
const seekTo = (time: number) => {
|
||||
setCurrentTime(time)
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = time
|
||||
}
|
||||
}
|
||||
|
||||
let savePositionInterval: number | null = null
|
||||
const onGlobalCtrl = (e: any) => {
|
||||
const name = e?.detail?.name
|
||||
const val = e?.detail?.val
|
||||
switch (name) {
|
||||
case 'play':
|
||||
void handlePlay()
|
||||
break
|
||||
case 'pause':
|
||||
void handlePause()
|
||||
break
|
||||
case 'toggle':
|
||||
void togglePlayPause()
|
||||
break
|
||||
case 'playPrev':
|
||||
void playPrevious()
|
||||
break
|
||||
case 'playNext':
|
||||
void playNext()
|
||||
break
|
||||
case 'volumeDelta':
|
||||
{
|
||||
const next = Math.max(0, Math.min(100, (Audio.value.volume || 0) + (Number(val) || 0)))
|
||||
setVolume(next)
|
||||
}
|
||||
break
|
||||
case 'seekDelta':
|
||||
{
|
||||
const a = Audio.value.audio
|
||||
if (a) {
|
||||
const cur = a.currentTime || 0
|
||||
const target = Math.max(0, Math.min(a.duration || 0, cur + (Number(val) || 0)))
|
||||
seekTo(target)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const initPlayback = async () => {
|
||||
initPlaylistEventListeners(localUserStore, playSong)
|
||||
if (userInfo.value.lastPlaySongId && list.value.length > 0) {
|
||||
const lastPlayedSong = list.value.find((song) => song.songmid === userInfo.value.lastPlaySongId)
|
||||
if (lastPlayedSong) {
|
||||
songInfo.value = { ...lastPlayedSong }
|
||||
mediaSessionController.updateMetadata({
|
||||
title: lastPlayedSong.name,
|
||||
artist: lastPlayedSong.singer,
|
||||
album: lastPlayedSong.albumName || '未知专辑',
|
||||
artworkUrl: lastPlayedSong.img || defaultCoverImg
|
||||
})
|
||||
if (!Audio.value.isPlay) {
|
||||
if (userInfo.value.currentTime && userInfo.value.currentTime > 0) {
|
||||
pendingRestorePosition = userInfo.value.currentTime
|
||||
pendingRestoreSongId = lastPlayedSong.songmid
|
||||
if (Audio.value.audio) {
|
||||
Audio.value.audio.currentTime = userInfo.value.currentTime
|
||||
}
|
||||
}
|
||||
try {
|
||||
const url = await getSongRealUrl(toRaw(lastPlayedSong))
|
||||
setUrl(url)
|
||||
} catch {}
|
||||
} else {
|
||||
if (Audio.value.audio) {
|
||||
mediaSessionController.updatePlaybackState(
|
||||
Audio.value.audio.paused ? 'paused' : 'playing'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
savePositionInterval = window.setInterval(() => {
|
||||
if (Audio.value.isPlay) {
|
||||
userInfo.value.currentTime = Audio.value.currentTime
|
||||
}
|
||||
}, 1000)
|
||||
window.addEventListener('global-music-control', onGlobalCtrl)
|
||||
controlAudio.subscribe('ended', () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
void playNext()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const destroyPlayback = () => {
|
||||
destroyPlaylistEventListeners()
|
||||
window.removeEventListener('global-music-control', onGlobalCtrl)
|
||||
if (savePositionInterval !== null) {
|
||||
clearInterval(savePositionInterval)
|
||||
savePositionInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
songInfo,
|
||||
playMode,
|
||||
isLoadingSong,
|
||||
initPlayback,
|
||||
destroyPlayback,
|
||||
playSong,
|
||||
playNext,
|
||||
playPrevious,
|
||||
updatePlayMode,
|
||||
togglePlayPause,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
setVolume,
|
||||
seekTo
|
||||
}
|
||||
109
src/renderer/src/utils/audio/globalControls.ts
Normal file
109
src/renderer/src/utils/audio/globalControls.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import mediaSessionController from '@renderer/utils/audio/useSmtc'
|
||||
|
||||
let installed = false
|
||||
|
||||
function dispatch(name: string, val?: any) {
|
||||
window.dispatchEvent(new CustomEvent('global-music-control', { detail: { name, val } }))
|
||||
}
|
||||
|
||||
export function installGlobalMusicControls() {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
const controlAudio = ControlAudioStore()
|
||||
|
||||
// 初始化 SMTC,并在音频元素可用时同步一次播放状态与动作处理器
|
||||
const tryInitSmtc = () => {
|
||||
const el = controlAudio.Audio.audio
|
||||
if (!el) return
|
||||
mediaSessionController.init(el, {
|
||||
play: () => dispatch('play'),
|
||||
pause: () => dispatch('pause'),
|
||||
playPrevious: () => dispatch('playPrev'),
|
||||
playNext: () => dispatch('playNext')
|
||||
})
|
||||
// 初始同步状态
|
||||
mediaSessionController.updatePlaybackState(el.paused ? 'paused' : 'playing')
|
||||
}
|
||||
// 尝试立即初始化一次
|
||||
tryInitSmtc()
|
||||
// 若 URL 变化或 audio 初始化稍后完成,由组件/Store 负责赋值;这里轮询几次兜底初始化
|
||||
let smtcTries = 0
|
||||
const smtcTimer = setInterval(() => {
|
||||
if (smtcTries > 20) {
|
||||
clearInterval(smtcTimer)
|
||||
return
|
||||
}
|
||||
smtcTries++
|
||||
if (controlAudio.Audio.audio) {
|
||||
tryInitSmtc()
|
||||
clearInterval(smtcTimer)
|
||||
}
|
||||
}, 150)
|
||||
|
||||
let keyThrottle = false
|
||||
const throttle = (cb: () => void, delay: number) => {
|
||||
if (keyThrottle) return
|
||||
keyThrottle = true
|
||||
setTimeout(() => {
|
||||
try {
|
||||
cb()
|
||||
} finally {
|
||||
keyThrottle = false
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
const tag = (target?.tagName || '').toLowerCase()
|
||||
const isEditable =
|
||||
target?.hasAttribute('contenteditable') ||
|
||||
tag === 'input' ||
|
||||
tag === 'textarea' ||
|
||||
(target as HTMLInputElement)?.type === 'text' ||
|
||||
(target as HTMLInputElement)?.type === 'search' ||
|
||||
(target as HTMLInputElement)?.type === 'password' ||
|
||||
(target as HTMLInputElement)?.type === 'email' ||
|
||||
(target as HTMLInputElement)?.type === 'url' ||
|
||||
(target as HTMLInputElement)?.type === 'number'
|
||||
|
||||
throttle(() => {
|
||||
if (e.code === 'Space') {
|
||||
if (isEditable) return
|
||||
e.preventDefault()
|
||||
dispatch('toggle')
|
||||
} else if (e.code === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
dispatch('volumeDelta', 5)
|
||||
} else if (e.code === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
dispatch('volumeDelta', -5)
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
dispatch('seekDelta', -5)
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
dispatch('seekDelta', 5)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
|
||||
// // 监听音频结束事件,根据播放模式播放下一首
|
||||
// controlAudio.subscribe('ended', () => {
|
||||
// window.requestAnimationFrame(() => {
|
||||
// console.log('播放结束')
|
||||
// dispatch('playNext')
|
||||
// })
|
||||
// })
|
||||
// 托盘或系统快捷键回调(若存在)
|
||||
try {
|
||||
const removeMusicCtrlListener = (window as any).api?.onMusicCtrl?.(() => {
|
||||
dispatch('toggle')
|
||||
})
|
||||
void removeMusicCtrlListener
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
229
src/renderer/src/utils/lyrics/desktopLyricBridge.ts
Normal file
229
src/renderer/src/utils/lyrics/desktopLyricBridge.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { LocalUserDetailStore } from '@renderer/store/LocalUserDetail'
|
||||
import { ControlAudioStore } from '@renderer/store/ControlAudio'
|
||||
import _ from 'lodash'
|
||||
import { toRaw } from 'vue'
|
||||
import {
|
||||
parseYrc,
|
||||
parseLrc,
|
||||
parseTTML,
|
||||
parseQrc
|
||||
} from '@applemusic-like-lyrics/lyric/pkg/amll_lyric.js'
|
||||
|
||||
interface LyricWord {
|
||||
word: string
|
||||
}
|
||||
interface LyricLine {
|
||||
startTime: number
|
||||
endTime: number
|
||||
words: LyricWord[]
|
||||
translatedLyric?: string
|
||||
}
|
||||
|
||||
let installed = false
|
||||
|
||||
function buildLyricPayload(lines: LyricLine[]) {
|
||||
return (lines || []).map((l) => ({
|
||||
content: (l.words || []).map((w) => w.word).join(''),
|
||||
tran: l.translatedLyric || ''
|
||||
}))
|
||||
}
|
||||
|
||||
function computeLyricIndex(timeMs: number, lines: LyricLine[]) {
|
||||
if (!lines || lines.length === 0) return -1
|
||||
const t = timeMs
|
||||
const i = lines.findIndex((l) => t >= l.startTime && t < l.endTime)
|
||||
if (i !== -1) return i
|
||||
for (let j = lines.length - 1; j >= 0; j--) {
|
||||
if (t >= lines[j].startTime) return j
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function mergeTranslation(base: LyricLine[], tlyric?: string): LyricLine[] {
|
||||
if (!tlyric || base.length === 0) return base
|
||||
const translated = parseLrc(tlyric) as LyricLine[]
|
||||
if (!translated || translated.length === 0) return base
|
||||
const joinWords = (line: LyricLine) => (line.words || []).map((w) => w.word).join('')
|
||||
|
||||
const translatedSorted = translated.slice().sort((a, b) => a.startTime - b.startTime)
|
||||
const baseTolerance = 300
|
||||
const ratioTolerance = 0.4
|
||||
if (base.length > 0) {
|
||||
const firstBase = base[0]
|
||||
const firstDuration = Math.max(1, firstBase.endTime - firstBase.startTime)
|
||||
const firstTol = Math.min(baseTolerance, firstDuration * ratioTolerance)
|
||||
let anchorIndex: number | null = null
|
||||
let bestDiff = Number.POSITIVE_INFINITY
|
||||
for (let i = 0; i < translatedSorted.length; i++) {
|
||||
const diff = Math.abs(translatedSorted[i].startTime - firstBase.startTime)
|
||||
if (diff <= firstTol && diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
anchorIndex = i
|
||||
}
|
||||
}
|
||||
if (anchorIndex !== null) {
|
||||
let j = anchorIndex
|
||||
for (let i = 0; i < base.length && j < translatedSorted.length; i++, j++) {
|
||||
const bl = base[i]
|
||||
const tl = translatedSorted[j]
|
||||
if (!tl?.words?.[0]?.word || tl.words[0].word === '//' || !bl?.words?.[0]?.word) continue
|
||||
const text = joinWords(tl)
|
||||
if (text) bl.translatedLyric = text
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
export function installDesktopLyricBridge() {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
const controlAudio = ControlAudioStore()
|
||||
const localUser = LocalUserDetailStore()
|
||||
|
||||
let currentLines: LyricLine[] = []
|
||||
let lastIndex = -1
|
||||
let abortCtrl: AbortController | null = null
|
||||
|
||||
async function fetchLyricsForCurrentSong() {
|
||||
const songId = localUser.userInfo.lastPlaySongId
|
||||
if (!songId) {
|
||||
currentLines = []
|
||||
lastIndex = -1
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', { index: -1, lyric: [] })
|
||||
return
|
||||
}
|
||||
const song = localUser.list.find((s: any) => s.songmid === songId)
|
||||
if (!song) {
|
||||
currentLines = []
|
||||
lastIndex = -1
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', { index: -1, lyric: [] })
|
||||
return
|
||||
}
|
||||
|
||||
// 取消上一轮请求
|
||||
if (abortCtrl) abortCtrl.abort()
|
||||
abortCtrl = new AbortController()
|
||||
|
||||
const source = (song as any).source || 'kg'
|
||||
let parsed: LyricLine[] = []
|
||||
try {
|
||||
if (source === 'wy' || source === 'tx') {
|
||||
try {
|
||||
const res = await (
|
||||
await fetch(
|
||||
`https://amll-ttml-db.stevexmh.net/${source === 'wy' ? 'ncm' : 'qq'}/${songId}`,
|
||||
{ signal: abortCtrl.signal }
|
||||
)
|
||||
).text()
|
||||
if (!res || res.length < 100) throw new Error('no ttml')
|
||||
parsed = (parseTTML(res) as any).lines as LyricLine[]
|
||||
} catch {
|
||||
const lyricData = await (window as any)?.api?.music?.requestSdk?.('getLyric', {
|
||||
source,
|
||||
songInfo: _.cloneDeep(toRaw(song))
|
||||
})
|
||||
if (lyricData?.crlyric) {
|
||||
parsed = (
|
||||
source === 'tx' ? parseQrc(lyricData.crlyric) : parseYrc(lyricData.crlyric)
|
||||
) as LyricLine[]
|
||||
} else if (lyricData?.lyric) {
|
||||
parsed = parseLrc(lyricData.lyric) as LyricLine[]
|
||||
}
|
||||
parsed = mergeTranslation(parsed, lyricData?.tlyric)
|
||||
}
|
||||
} else if (source !== 'local') {
|
||||
const lyricData = await (window as any)?.api?.music?.requestSdk?.('getLyric', {
|
||||
source,
|
||||
songInfo: _.cloneDeep(toRaw(song))
|
||||
})
|
||||
if (lyricData?.crlyric) {
|
||||
parsed = (
|
||||
source === 'tx' ? parseQrc(lyricData.crlyric) : parseYrc(lyricData.crlyric)
|
||||
) as LyricLine[]
|
||||
} else if (lyricData?.lyric) {
|
||||
parsed = parseLrc(lyricData.lyric) as LyricLine[]
|
||||
}
|
||||
parsed = mergeTranslation(parsed, lyricData?.tlyric)
|
||||
} else {
|
||||
const text = (song as any).lrc as string | null
|
||||
if (text && (/^\[(\d+),\d+\]/.test(text) || /\(\d+,\d+,\d+\)/.test(text))) {
|
||||
parsed = text ? (parseQrc(text) as any) : []
|
||||
} else {
|
||||
parsed = text ? (parseLrc(text) as any) : []
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取歌词失败:', e)
|
||||
parsed = []
|
||||
}
|
||||
|
||||
currentLines = parsed || []
|
||||
lastIndex = -1
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', {
|
||||
index: -1,
|
||||
lyric: buildLyricPayload(currentLines)
|
||||
})
|
||||
// 提示前端进入准备态:先渲染 1、2 句左右铺开
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-index', -1)
|
||||
|
||||
// 同步歌名
|
||||
try {
|
||||
const name = (song as any)?.name || ''
|
||||
const artist = (song as any)?.singer || ''
|
||||
const title = [name, artist].filter(Boolean).join(' - ')
|
||||
if (title) (window as any)?.electron?.ipcRenderer?.send?.('play-song-change', title)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 监听歌曲切换
|
||||
let lastSongId: any = undefined
|
||||
setInterval(() => {
|
||||
if (localUser.userInfo.lastPlaySongId !== lastSongId) {
|
||||
lastSongId = localUser.userInfo.lastPlaySongId
|
||||
fetchLyricsForCurrentSong()
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 播放状态推送
|
||||
let lastPlayState: any = undefined
|
||||
setInterval(() => {
|
||||
if (controlAudio.Audio.isPlay !== lastPlayState) {
|
||||
lastPlayState = controlAudio.Audio.isPlay
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-status-change', lastPlayState)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
// 时间推进与当前行/进度推送
|
||||
setInterval(() => {
|
||||
const a = controlAudio.Audio
|
||||
const ms = Math.round((a?.currentTime || 0) * 1000)
|
||||
const idx = computeLyricIndex(ms, currentLines)
|
||||
|
||||
// 计算当前行进度(0~1)
|
||||
let progress = 0
|
||||
if (idx >= 0 && currentLines[idx]) {
|
||||
const line = currentLines[idx]
|
||||
const dur = Math.max(1, (line.endTime ?? line.startTime + 1) - line.startTime)
|
||||
progress = Math.min(1, Math.max(0, (ms - line.startTime) / dur))
|
||||
}
|
||||
|
||||
// 首先推送进度,便于前端做 30% 判定(避免 setTimeout 带来的抖动)
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-progress', {
|
||||
index: idx,
|
||||
progress
|
||||
})
|
||||
|
||||
// 当行变化时,推送 index(立即切换高亮),并附带完整歌词集合(仅在变化时下发,减少开销)
|
||||
if (idx !== lastIndex) {
|
||||
lastIndex = idx
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-index', idx)
|
||||
;(window as any)?.electron?.ipcRenderer?.send?.('play-lyric-change', {
|
||||
index: idx,
|
||||
lyric: buildLyricPayload(currentLines)
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
4202
src/renderer/src/utils/playlist/lx_list_part_list__name_default.json
Normal file
4202
src/renderer/src/utils/playlist/lx_list_part_list__name_default.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -34,6 +34,13 @@ const qualityKey = Object.keys(qualityMap)
|
||||
*/
|
||||
export async function getSongRealUrl(song: SongList): Promise<string> {
|
||||
try {
|
||||
if ((song as any).source === 'local') {
|
||||
const id = (song as any).songmid
|
||||
const url = await (window as any).api.localMusic.getUrlById(id)
|
||||
if (typeof url === 'object' && url?.error) throw new Error(url.error)
|
||||
if (typeof url === 'string') return url
|
||||
throw new Error('本地歌曲URL获取失败')
|
||||
}
|
||||
// 获取当前用户的信息
|
||||
const LocalUserDetail = LocalUserDetailStore()
|
||||
// 通过统一的request方法获取真实的播放URL
|
||||
@@ -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) {
|
||||
|
||||
@@ -23,10 +23,3 @@
|
||||
<PlayMusic />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animate__animated {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -221,6 +221,8 @@ onUnmounted(() => {
|
||||
.find-container {
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -233,6 +233,7 @@ const handleScroll = (event: Event) => {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
|
||||
2139
src/renderer/src/views/music/songlist.vue
Normal file
2139
src/renderer/src/views/music/songlist.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
797
src/web/lyric.html
Normal 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>
|
||||
Reference in New Issue
Block a user