mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 03:14:57 +08:00
Compare commits
32 Commits
dev-lyric
...
1b6ebd9c7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b6ebd9c7c | ||
|
|
7721251a98 | ||
|
|
320047ca9c | ||
|
|
73be8d8657 | ||
|
|
d527b076dc | ||
|
|
7bf9c6d7bc | ||
|
|
4dfd897401 | ||
|
|
f0a6526fd1 | ||
|
|
6d4f78413b | ||
|
|
8866996e5b | ||
|
|
5d21709c58 | ||
|
|
54d77d08eb | ||
|
|
bd25a8fe2e | ||
|
|
f0270a2fb0 | ||
|
|
3ebfccdfcc | ||
|
|
c29f2ed0a0 | ||
|
|
6caf99da09 | ||
|
|
ad27d1eaea | ||
|
|
8846d7f669 | ||
|
|
807b72ed9e | ||
|
|
d89be488e2 | ||
|
|
4bf986b763 | ||
|
|
ffb1fcc1ea | ||
|
|
ea822f91e8 | ||
|
|
5e260ffc0d | ||
|
|
e3dcde71e8 | ||
|
|
7de1355f18 | ||
|
|
0d66ced637 | ||
|
|
ae1ee71e75 | ||
|
|
84d9c999eb | ||
|
|
1a36fbf1d5 | ||
|
|
e0e62cd906 |
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,35 +1,75 @@
|
||||
name: 遇到问题
|
||||
description: 关于使用过程中遇到的问题
|
||||
title: 请填写标题
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: input
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
在此之前,我们默认你已经知道该如何提问。简而言之,**你要精确地描述问题并提供充足的信息**
|
||||
|
||||
此外,如果需要,你可以使用 <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> 打开开发者工具为我们提供信息
|
||||
|
||||
- type: checkboxes
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: "检查清单"
|
||||
description: |
|
||||
我们需要了解一些信息,你需要检查下面的检查项 <br />
|
||||
**这里并不是每一个检查项都必要,根据你的真实情况勾选即可**
|
||||
options:
|
||||
- label: "我已检索仓库中所有的 Issues,确保我**没有重复提交问题**;或有相似 Issue,但我觉得我的情况不包含在那个相似 Issue 之内"
|
||||
- label: "我已经找到了可以复现这个问题的方法,并且写在了下面的「具体信息」中"
|
||||
- label: "此问题可以在我的设备和当前环境中**稳定复现**"
|
||||
- label: "此问题可以在最新版本 (Latest Release) 中复现"
|
||||
- label: "此问题是在我更新到当前版本后**才**出现的"
|
||||
|
||||
- type: dropdown
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "是网页端还是客户端"
|
||||
placeholder: "客户端"
|
||||
options:
|
||||
- "客户端"
|
||||
- "网页端"
|
||||
default: 0
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "当前系统环境"
|
||||
placeholder: "win11"
|
||||
placeholder: "如:Windows 11"
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
attributes:
|
||||
label: "当前 Node.js 及 npm 版本"
|
||||
placeholder: "v18.16.0 / v9.6.7"
|
||||
placeholder: "如:v18.16.0 / v9.6.7 (选填)"
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "当前版本"
|
||||
placeholder: "v1.0.0"
|
||||
description: |
|
||||
填写关于软件里的或 Releases 中版本号即可 <br />
|
||||
如果是自行构建或从 GitHub Actions 下载的开发版,还需要提供 Commit ID
|
||||
placeholder: "如:v1.0.0"
|
||||
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: "具体信息"
|
||||
description: "请填写完整的复现步骤和遇到的问题,包括但不限于报错信息、控制台输出、网络请求等"
|
||||
description: |
|
||||
请填写完整的复现步骤和遇到的问题,还请尽量提供所有可能的信息,提供包括但不限于:
|
||||
|
||||
- 分步的复现步骤,可以使用 `1. xxx` (换行) `2. xxx` 的格式
|
||||
- 截图(如果结合复现步骤还是无法详细表述,甚至可以录屏)
|
||||
- 开发者工具中的:控制台输出报错、网络请求等
|
||||
- 出现问题的在线歌曲链接、出现问题的文件(本地歌曲、歌词等)的下载链接
|
||||
|
||||
等信息,以更好地帮助我们解决你的问题
|
||||
placeholder: "请填写具体的复现步骤和遇到的问题"
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
|
||||
- 欢迎各位大佬 `Star` 😍
|
||||
|
||||
## 💬 交流群
|
||||
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw" target="_blank">
|
||||
|
||||

|
||||
|
||||
</a>
|
||||
|
||||
## 👀 Demo
|
||||
|
||||
- [SPlayer](https://music.imsyy.top/)
|
||||
|
||||
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -146,6 +146,7 @@ declare module 'vue' {
|
||||
SongList: typeof import('./src/components/List/SongList.vue')['default']
|
||||
SongListCard: typeof import('./src/components/Card/SongListCard.vue')['default']
|
||||
SongListMenu: typeof import('./src/components/Menu/SongListMenu.vue')['default']
|
||||
SongUnlockManager: typeof import('./src/components/Modal/SongUnlockManager.vue')['default']
|
||||
SvgIcon: typeof import('./src/components/Global/SvgIcon.vue')['default']
|
||||
TextContainer: typeof import('./src/components/Global/TextContainer.vue')['default']
|
||||
UpdateApp: typeof import('./src/components/Modal/UpdateApp.vue')['default']
|
||||
|
||||
@@ -204,9 +204,11 @@ const initFileIpc = (): void => {
|
||||
|
||||
try {
|
||||
// 定义需要查找的模式
|
||||
// 此处的 `{,*.}` 表示这里可以取 `` (empty) 也可以取 `*.`
|
||||
// 将歌词文件命名为 `歌曲ID.后缀名` 或者 `任意前缀.歌曲ID.后缀名` 均可
|
||||
const patterns = {
|
||||
ttml: `**/${id}.ttml`,
|
||||
lrc: `**/${id}.lrc`,
|
||||
ttml: `**/{,*.}${id}.ttml`,
|
||||
lrc: `**/{,*.}${id}.lrc`,
|
||||
};
|
||||
|
||||
// 遍历每一个目录
|
||||
|
||||
107
electron/server/unblock/gequbao.ts
Normal file
107
electron/server/unblock/gequbao.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { SongUrlResult } from "./unblock";
|
||||
import { serverLog } from "../../main/logger";
|
||||
import axios from "axios";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* 搜索歌曲获取 ID
|
||||
* @param keyword 搜索关键词
|
||||
* @returns 歌曲 ID 或 null
|
||||
*/
|
||||
const search = async (keyword: string): Promise<string | null> => {
|
||||
try {
|
||||
const searchUrl = `https://www.gequbao.com/s/${encodeURIComponent(keyword)}`;
|
||||
const { data } = await axios.get(searchUrl);
|
||||
|
||||
// 匹配第一个歌曲链接 /music/12345
|
||||
// <a href="/music/17165" target="_blank" class="music-link d-block">
|
||||
const match = data.match(
|
||||
/<a href="\/music\/(\d+)" target="_blank" class="music-link d-block">/,
|
||||
);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
serverLog.error("❌ Get GequbaoSongId Error:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取播放 ID
|
||||
* @param id 歌曲 ID
|
||||
* @returns 播放 ID 或 null
|
||||
*/
|
||||
const getPlayId = async (id: string): Promise<string | null> => {
|
||||
try {
|
||||
const url = `https://www.gequbao.com/music/${id}`;
|
||||
const { data } = await axios.get(url);
|
||||
|
||||
// 匹配 window.appData 中的 play_id
|
||||
// "play_id":"EFwMVSQDBgsBQV5WBCUDAVkCSQ9WX3kFXV9XEl0KBSEaVldTR19NVndQVlhXRl5cUA=="
|
||||
const match = data.match(/"play_id":"(.*?)"/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
serverLog.error("❌ Get GequbaoPlayId Error:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取歌曲 URL
|
||||
* @param keyword 搜索关键词
|
||||
* @returns 包含歌曲 URL 的结果对象
|
||||
*/
|
||||
const getGequbaoSongUrl = async (keyword: string): Promise<SongUrlResult> => {
|
||||
try {
|
||||
if (!keyword) return { code: 404, url: null };
|
||||
|
||||
// 1. 获取 ID
|
||||
const id = await search(keyword);
|
||||
if (!id) return { code: 404, url: null };
|
||||
|
||||
// 2. 获取 play_id
|
||||
const playId = await getPlayId(id);
|
||||
if (!playId) return { code: 404, url: null };
|
||||
|
||||
// 3. 获取播放链接
|
||||
const url = "https://www.gequbao.com/api/play-url";
|
||||
const headers = {
|
||||
accept: "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
pragma: "no-cache",
|
||||
priority: "u=1, i",
|
||||
"sec-ch-ua": '"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
cookie: `server_name_session=${randomBytes(16).toString("hex")}`,
|
||||
Referer: `https://www.gequbao.com/music/${id}`,
|
||||
};
|
||||
|
||||
const body = `id=${encodeURIComponent(playId)}`;
|
||||
|
||||
const { data } = await axios.post(url, body, { headers });
|
||||
|
||||
if (data.code === 1 && data.data && data.data.url) {
|
||||
serverLog.log("🔗 GequbaoSong URL:", data.data.url);
|
||||
return { code: 200, url: data.data.url };
|
||||
}
|
||||
|
||||
return { code: 404, url: null };
|
||||
} catch (error) {
|
||||
serverLog.error("❌ Get GequbaoSong URL Error:", error);
|
||||
return { code: 404, url: null };
|
||||
}
|
||||
};
|
||||
|
||||
export default getGequbaoSongUrl;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { SongUrlResult } from "./unblock";
|
||||
import { serverLog } from "../../main/logger";
|
||||
import getKuwoSongUrl from "./kuwo";
|
||||
import axios from "axios";
|
||||
import getKuwoSongUrl from "./kuwo";
|
||||
import getBodianSongUrl from "./bodian";
|
||||
import getGequbaoSongUrl from "./gequbao";
|
||||
|
||||
/**
|
||||
* 直接获取 网易云云盘 链接
|
||||
@@ -74,6 +75,18 @@ export const initUnblockAPI = async (fastify: FastifyInstance) => {
|
||||
return reply.send(result);
|
||||
},
|
||||
);
|
||||
// gequbao
|
||||
fastify.get(
|
||||
"/unblock/gequbao",
|
||||
async (
|
||||
req: FastifyRequest<{ Querystring: { [key: string]: string } }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { keyword } = req.query;
|
||||
const result = await getGequbaoSongUrl(keyword);
|
||||
return reply.send(result);
|
||||
},
|
||||
);
|
||||
|
||||
serverLog.info("🌐 Register UnblockAPI successfully");
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "splayer",
|
||||
"productName": "SPlayer",
|
||||
"version": "3.0.0-beta.5",
|
||||
"version": "3.0.0-beta.6",
|
||||
"description": "A minimalist music player",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "imsyy",
|
||||
@@ -49,6 +49,7 @@
|
||||
"@pixi/filter-color-matrix": "^7.4.3",
|
||||
"@pixi/sprite": "^7.4.3",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@vueuse/integrations": "^14.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"axios-retry": "^4.5.0",
|
||||
"change-case": "^5.4.4",
|
||||
@@ -72,6 +73,7 @@
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"plyr": "^3.8.3",
|
||||
"sortablejs": "^1",
|
||||
"vue-virt-list": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
||||
'@vueuse/core':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.0(vue@3.5.24(typescript@5.9.3))
|
||||
'@vueuse/integrations':
|
||||
specifier: ^14.0.0
|
||||
version: 14.0.0(async-validator@4.2.5)(axios@1.13.2)(change-case@5.4.4)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.24(typescript@5.9.3))
|
||||
axios:
|
||||
specifier: ^1.13.2
|
||||
version: 1.13.2
|
||||
@@ -129,6 +132,9 @@ importers:
|
||||
plyr:
|
||||
specifier: ^3.8.3
|
||||
version: 3.8.3
|
||||
sortablejs:
|
||||
specifier: ^1
|
||||
version: 1.15.6
|
||||
vue-virt-list:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(vue@3.5.24(typescript@5.9.3))
|
||||
@@ -1514,14 +1520,69 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/core@14.0.0':
|
||||
resolution: {integrity: sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/integrations@14.0.0':
|
||||
resolution: {integrity: sha512-5A0X7q9qyLtM3xyghq5nK/NEESf7cpcZlkQgXTMuW4JWiAMYxc1ImdhhGrk4negFBsq3ejvAlRmLdNrkcTzk1Q==}
|
||||
peerDependencies:
|
||||
async-validator: ^4
|
||||
axios: ^1
|
||||
change-case: ^5
|
||||
drauu: ^0.4
|
||||
focus-trap: ^7
|
||||
fuse.js: ^7
|
||||
idb-keyval: ^6
|
||||
jwt-decode: ^4
|
||||
nprogress: ^0.2
|
||||
qrcode: ^1.5
|
||||
sortablejs: ^1
|
||||
universal-cookie: ^7 || ^8
|
||||
vue: ^3.5.0
|
||||
peerDependenciesMeta:
|
||||
async-validator:
|
||||
optional: true
|
||||
axios:
|
||||
optional: true
|
||||
change-case:
|
||||
optional: true
|
||||
drauu:
|
||||
optional: true
|
||||
focus-trap:
|
||||
optional: true
|
||||
fuse.js:
|
||||
optional: true
|
||||
idb-keyval:
|
||||
optional: true
|
||||
jwt-decode:
|
||||
optional: true
|
||||
nprogress:
|
||||
optional: true
|
||||
qrcode:
|
||||
optional: true
|
||||
sortablejs:
|
||||
optional: true
|
||||
universal-cookie:
|
||||
optional: true
|
||||
|
||||
'@vueuse/metadata@13.9.0':
|
||||
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
|
||||
|
||||
'@vueuse/metadata@14.0.0':
|
||||
resolution: {integrity: sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==}
|
||||
|
||||
'@vueuse/shared@13.9.0':
|
||||
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/shared@14.0.0':
|
||||
resolution: {integrity: sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -3932,6 +3993,9 @@ packages:
|
||||
resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sortablejs@1.15.6:
|
||||
resolution: {integrity: sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5876,12 +5940,37 @@ snapshots:
|
||||
'@vueuse/shared': 13.9.0(vue@3.5.24(typescript@5.9.3))
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
'@vueuse/core@14.0.0(vue@3.5.24(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.0.0
|
||||
'@vueuse/shared': 14.0.0(vue@3.5.24(typescript@5.9.3))
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
'@vueuse/integrations@14.0.0(async-validator@4.2.5)(axios@1.13.2)(change-case@5.4.4)(qrcode@1.5.4)(sortablejs@1.15.6)(vue@3.5.24(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 14.0.0(vue@3.5.24(typescript@5.9.3))
|
||||
'@vueuse/shared': 14.0.0(vue@3.5.24(typescript@5.9.3))
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
async-validator: 4.2.5
|
||||
axios: 1.13.2
|
||||
change-case: 5.4.4
|
||||
qrcode: 1.5.4
|
||||
sortablejs: 1.15.6
|
||||
|
||||
'@vueuse/metadata@13.9.0': {}
|
||||
|
||||
'@vueuse/metadata@14.0.0': {}
|
||||
|
||||
'@vueuse/shared@13.9.0(vue@3.5.24(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
'@vueuse/shared@14.0.0(vue@3.5.24(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.24(typescript@5.9.3)
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
@@ -8598,6 +8687,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-plain-obj: 1.1.0
|
||||
|
||||
sortablejs@1.15.6: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
||||
BIN
screenshots/welcome.png
Normal file
BIN
screenshots/welcome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -1,5 +1,6 @@
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { songLevelData } from "@/utils/meta";
|
||||
import { SongUnlockServer } from "@/utils/songManager";
|
||||
import request from "@/utils/request";
|
||||
|
||||
// 获取歌曲详情
|
||||
@@ -47,16 +48,12 @@ export const songUrl = (
|
||||
};
|
||||
|
||||
// 获取解锁歌曲 URL
|
||||
export const unlockSongUrl = (
|
||||
id: number,
|
||||
keyword: string,
|
||||
server: "netease" | "kuwo" | "bodian",
|
||||
) => {
|
||||
const params = server === "netease" ? { id } : { keyword };
|
||||
export const unlockSongUrl = (id: number, keyword: string, server: SongUnlockServer) => {
|
||||
const params = server === SongUnlockServer.NETEASE ? { id } : { keyword };
|
||||
return request({
|
||||
baseURL: "/api/unblock",
|
||||
url: `/${server}`,
|
||||
params,
|
||||
params: { ...params, noCookie: true },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
1
src/assets/icons/QQ.svg
Normal file
1
src/assets/icons/QQ.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Remix Icon by Remix Design - https://github.com/Remix-Design/RemixIcon/blob/master/License --><path fill="currentColor" d="M19.914 14.529a32 32 0 0 0-.676-1.886l-.91-2.246c.002-.026.013-.468.013-.696C18.34 5.86 16.508 2 12 2S5.66 5.86 5.66 9.7c0 .229.011.671.012.697l-.91 2.246a32 32 0 0 0-.675 1.886c-.86 2.737-.581 3.87-.369 3.895c.455.054 1.771-2.06 1.771-2.06c0 1.224.637 2.822 2.016 3.976c-.515.157-1.147.399-1.554.695c-.365.267-.319.54-.253.65c.289.481 4.955.307 6.303.157c1.347.15 6.014.324 6.302-.158c.066-.11.112-.382-.253-.649c-.407-.296-1.039-.538-1.555-.695c1.379-1.154 2.016-2.752 2.016-3.977c0 0 1.316 2.115 1.771 2.06c.212-.025.49-1.157-.37-3.894"/></svg>
|
||||
|
After Width: | Height: | Size: 768 B |
@@ -158,7 +158,7 @@ import { openJumpArtist } from "@/utils/modal";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import { isObject } from "lodash-es";
|
||||
import { formatTimestamp, msToTime } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import blob from "@/utils/blob";
|
||||
import { isElectron } from "@/utils/env";
|
||||
|
||||
@@ -174,6 +174,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -217,6 +217,9 @@ const changeGlobalTheme = () => {
|
||||
railColor: toRGBA(primaryRGB, 0.2),
|
||||
railColorHover: toRGBA(primaryRGB, 0.3),
|
||||
},
|
||||
Popover: {
|
||||
color: `rgb(${surfaceContainerRGB})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,14 +28,16 @@ const isTextOverflowing = ref(false);
|
||||
|
||||
// 容器宽度
|
||||
const { width: textContainerWidth } = useElementSize(textContainerRef);
|
||||
// 文本宽度
|
||||
const { width: textWidth } = useElementSize(textRef);
|
||||
|
||||
// 检查文本是否超出宽度
|
||||
const checkTextWidth = () => {
|
||||
if (textRef.value && textContainerRef.value) {
|
||||
const textWidth = textRef.value.offsetWidth;
|
||||
const containerWidth = textContainerWidth.value;
|
||||
const currentTextWidth = textRef.value.offsetWidth;
|
||||
const currentContainerWidth = textContainerWidth.value;
|
||||
// 判断阈值
|
||||
isTextOverflowing.value = textWidth > containerWidth + 2;
|
||||
isTextOverflowing.value = currentTextWidth > currentContainerWidth + 2;
|
||||
}
|
||||
// 更新状态
|
||||
updateScroll();
|
||||
@@ -56,6 +58,8 @@ let scrollTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 开始滚动
|
||||
const startScrolling = () => {
|
||||
// 先停止之前的滚动
|
||||
stopScrolling();
|
||||
if (!textRef.value || !textContainerRef.value || !scrollWrapperRef.value || !textCloneRef.value)
|
||||
return;
|
||||
// 设置滚动速度( 单位:像素/帧 )
|
||||
@@ -97,7 +101,7 @@ const stopScrolling = () => {
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.text, textContainerWidth.value, textCloneRef.value],
|
||||
() => [props.text, textContainerWidth.value, textWidth.value, textCloneRef.value],
|
||||
() => {
|
||||
nextTick(checkTextWidth);
|
||||
},
|
||||
|
||||
@@ -34,9 +34,10 @@ import { openCreatePlaylist } from "@/utils/modal";
|
||||
import { debounce } from "lodash-es";
|
||||
import { isLogin } from "@/utils/auth";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -91,9 +91,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownOption } from "naive-ui";
|
||||
import { useSettingStore } from "@/stores";
|
||||
import { renderIcon } from "@/utils/helper";
|
||||
import { openLink, renderIcon } from "@/utils/helper";
|
||||
import { openSetting } from "@/utils/modal";
|
||||
import { isDev, isElectron } from "@/utils/env";
|
||||
import packageJson from "@/../package.json";
|
||||
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
@@ -155,7 +156,30 @@ const setOptions = computed<DropdownOption[]>(() => [
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "header-divider",
|
||||
key: "divider-1",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
// 交流群
|
||||
key: "qq",
|
||||
label: "加入交流群",
|
||||
props: {
|
||||
onClick: () =>
|
||||
openLink(
|
||||
"https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw",
|
||||
),
|
||||
},
|
||||
icon: renderIcon("QQ"),
|
||||
},
|
||||
{
|
||||
// 交流群
|
||||
key: "github",
|
||||
label: "开源仓库",
|
||||
props: { onClick: () => openLink(packageJson.github) },
|
||||
icon: renderIcon("Github"),
|
||||
},
|
||||
{
|
||||
key: "divider-2",
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -218,6 +218,9 @@ const likeComment = debounce(async (data: CommentType) => {
|
||||
color: var(--primary-hex);
|
||||
}
|
||||
}
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
.reply {
|
||||
width: 100%;
|
||||
@@ -226,6 +229,9 @@ const likeComment = debounce(async (data: CommentType) => {
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
background-color: rgba(var(--primary), 0.12);
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
.meta {
|
||||
padding-top: 12px;
|
||||
|
||||
@@ -121,8 +121,8 @@ import { formatSongsList } from "@/utils/format";
|
||||
import { songDetail } from "@/api/song";
|
||||
import { playlistAllSongs } from "@/api/playlist";
|
||||
import { radioAllProgram } from "@/api/radio";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import CoverMenu from "@/components/Menu/CoverMenu.vue";
|
||||
import player from "@/utils/player";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
|
||||
interface Props {
|
||||
@@ -145,6 +145,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
|
||||
@@ -129,8 +129,8 @@ import { VirtList } from "vue-virt-list";
|
||||
import { entries, isEmpty } from "lodash-es";
|
||||
import { sortOptions } from "@/utils/meta";
|
||||
import { renderIcon } from "@/utils/helper";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import SongListMenu from "@/components/Menu/SongListMenu.vue";
|
||||
import player from "@/utils/player";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -177,6 +177,7 @@ const emit = defineEmits<{
|
||||
removeSong: [id: number[]];
|
||||
}>();
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -234,10 +235,13 @@ const listData = computed<SongType[]>(() => {
|
||||
const listKey = computed(() => {
|
||||
// 每日推荐
|
||||
if (props.isDailyRecommend) {
|
||||
return musicStore.dailySongsData.timestamp || 0;
|
||||
return `daily-${musicStore.dailySongsData.timestamp || 0}`;
|
||||
}
|
||||
// 其他列表长度(检测增删操作)
|
||||
return listData.value?.length || 0;
|
||||
// 使用 playListId 作为主要 key
|
||||
if (props.playListId) {
|
||||
return `playlist-${props.playListId}`;
|
||||
}
|
||||
return `type-${props.type}`;
|
||||
});
|
||||
|
||||
// 列表是否具有播放歌曲
|
||||
@@ -261,7 +265,9 @@ const sortMenuOptions = computed<DropdownOption[]>(() =>
|
||||
// 列表滚动
|
||||
const onScroll = (e: Event) => {
|
||||
emit("scroll", e);
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop;
|
||||
const top = (e.target as HTMLElement).scrollTop;
|
||||
scrollTop.value = top;
|
||||
offset.value = top;
|
||||
};
|
||||
|
||||
// 列表触底
|
||||
|
||||
@@ -31,11 +31,12 @@ import { deleteSongs, isLogin } from "@/utils/auth";
|
||||
import { songUrl } from "@/api/song";
|
||||
import { dailyRecommendDislike } from "@/api/rec";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const emit = defineEmits<{ removeSong: [index: number[]] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const localStore = useLocalStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -66,8 +66,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore } from "@/stores";
|
||||
import { convertSecondsToTime } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 自定义时长
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore } from "@/stores";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
</script>
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
<script setup lang="ts">
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { useStatusStore } from "@/stores";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
type PresetKey = keyof typeof presetList;
|
||||
|
||||
62
src/components/Modal/SongUnlockManager.vue
Normal file
62
src/components/Modal/SongUnlockManager.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="song-unlock-manager">
|
||||
<n-alert title="免责声明" type="info">
|
||||
本功能仅作为测试使用,资源来自网络,若侵犯到您的权益,请及时联系我们删除
|
||||
</n-alert>
|
||||
<div ref="sortableRef" class="sortable-list">
|
||||
<n-card
|
||||
v-for="item in settingStore.songUnlockServer"
|
||||
:key="item.key"
|
||||
:content-style="{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
}"
|
||||
class="sortable-item"
|
||||
>
|
||||
<SvgIcon :depth="3" name="Menu" />
|
||||
<n-text class="name">{{ item.key }}</n-text>
|
||||
<n-switch v-model:value="item.enabled" :round="false" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from "@/stores";
|
||||
import { useSortable } from "@vueuse/integrations/useSortable";
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const sortableRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 拖拽
|
||||
useSortable(sortableRef, settingStore.songUnlockServer, {
|
||||
animation: 150,
|
||||
handle: ".n-icon",
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sortable-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
.sortable-item {
|
||||
border-radius: 8px;
|
||||
.n-icon {
|
||||
font-size: 16px;
|
||||
cursor: move;
|
||||
}
|
||||
.name {
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
.n-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,21 +30,14 @@
|
||||
:class="[
|
||||
'player-content',
|
||||
{
|
||||
'no-lrc': noLrc,
|
||||
pure: statusStore.pureLyricMode && musicStore.isHasLrc,
|
||||
'no-lrc': !musicStore.isHasLrc || (!musicStore.isHasLrc && !statusStore.lyricLoading),
|
||||
},
|
||||
]"
|
||||
@mousemove="playerMove"
|
||||
>
|
||||
<Transition name="zoom">
|
||||
<div
|
||||
v-if="
|
||||
!(statusStore.pureLyricMode && musicStore.isHasLrc) ||
|
||||
musicStore.playSong.type === 'radio'
|
||||
"
|
||||
:key="musicStore.playSong.id"
|
||||
class="content-left"
|
||||
>
|
||||
<div v-if="!pureLyricMode" :key="musicStore.playSong.id" class="content-left">
|
||||
<!-- 封面 -->
|
||||
<PlayerCover />
|
||||
<!-- 数据 -->
|
||||
@@ -58,6 +51,7 @@
|
||||
v-if="statusStore.pureLyricMode && musicStore.isHasLrc"
|
||||
:center="statusStore.pureLyricMode"
|
||||
:theme="statusStore.mainColor"
|
||||
:light="pureLyricMode"
|
||||
/>
|
||||
<!-- 歌词 -->
|
||||
<MainAMLyric v-if="settingStore.useAMLyrics" />
|
||||
@@ -85,8 +79,9 @@
|
||||
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { throttle } from "lodash-es";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
@@ -96,6 +91,20 @@ const isShowComment = computed<boolean>(
|
||||
() => !musicStore.playSong.path && statusStore.showPlayerComment,
|
||||
);
|
||||
|
||||
/** 没有歌词 */
|
||||
const noLrc = computed<boolean>(() => {
|
||||
const noNormalLrc = !musicStore.isHasLrc;
|
||||
const noYrcAvailable = !musicStore.isHasYrc || !settingStore.showYrc;
|
||||
// const notLoading = !statusStore.lyricLoading;
|
||||
|
||||
return noNormalLrc && noYrcAvailable;
|
||||
});
|
||||
|
||||
/** 是否处于纯净模式 */
|
||||
const pureLyricMode = computed<boolean>(
|
||||
() => (statusStore.pureLyricMode && musicStore.isHasLrc) || musicStore.playSong.type === "radio",
|
||||
);
|
||||
|
||||
// 主内容 key
|
||||
const playerContentKey = computed(() => `${statusStore.pureLyricMode}`);
|
||||
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LyricPlayer } from "@applemusic-like-lyrics/vue";
|
||||
import { LyricLine } from "@applemusic-like-lyrics/core";
|
||||
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
|
||||
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import { getLyricLanguage } from "@/utils/format";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import LyricMenu from "./LyricMenu.vue";
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
@@ -171,11 +171,12 @@
|
||||
import { LyricWord } from "@applemusic-like-lyrics/lyric";
|
||||
import { NScrollbar } from "naive-ui";
|
||||
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { getLyricLanguage } from "@/utils/format";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import LyricMenu from "./LyricMenu.vue";
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
@@ -205,7 +206,7 @@ const lyricsScroll = (index: number) => {
|
||||
const container = lrcItemDom.parentElement;
|
||||
if (!container) return;
|
||||
// 调整滚动的距离
|
||||
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 80;
|
||||
const scrollDistance = lrcItemDom.offsetTop - container.offsetTop - 100;
|
||||
// 开始滚动
|
||||
if (settingStore.lyricsScrollPosition === "center") {
|
||||
lrcItemDom?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -399,8 +400,8 @@ onBeforeUnmount(() => {
|
||||
top: 0;
|
||||
transform: none;
|
||||
will-change: -webkit-mask-position-x, transform, opacity;
|
||||
// padding: 2px 8px;
|
||||
// margin: -2px -8px;
|
||||
padding: 0.3em 0;
|
||||
margin: -0.3em 0;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
|
||||
@@ -117,8 +117,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore, useDataStore } from "@/stores";
|
||||
import type { VirtualListInst } from "naive-ui";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
|
||||
@@ -199,9 +199,10 @@ import {
|
||||
openJumpArtist,
|
||||
openPlaylistAdd,
|
||||
} from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
@@ -262,6 +263,7 @@ const isShowLyrics = computed(() => {
|
||||
const isHasLrc = musicStore.isHasLrc;
|
||||
return (
|
||||
isHasLrc &&
|
||||
!statusStore.lyricLoading &&
|
||||
settingStore.barLyricShow &&
|
||||
musicStore.playSong.type !== "radio" &&
|
||||
statusStore.playStatus &&
|
||||
@@ -293,7 +295,7 @@ const instantLyrics = computed(() => {
|
||||
background-color: var(--surface-container-hex);
|
||||
// background-color: rgba(var(--surface-container), 0.28);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
transition: bottom 0.3s;
|
||||
z-index: 10;
|
||||
@@ -321,8 +323,8 @@ const instantLyrics = computed(() => {
|
||||
.play-data {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
max-width: 640px;
|
||||
.cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -373,7 +375,8 @@ const instantLyrics = computed(() => {
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -381,18 +384,21 @@ const instantLyrics = computed(() => {
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 100px);
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.n-tag {
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.like {
|
||||
color: var(--primary-hex);
|
||||
margin-left: 8px;
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
@@ -403,6 +409,7 @@ const instantLyrics = computed(() => {
|
||||
.more {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.artists {
|
||||
@@ -446,6 +453,7 @@ const instantLyrics = computed(() => {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 40px;
|
||||
.play-pause {
|
||||
--n-width: 44px;
|
||||
--n-height: 44px;
|
||||
@@ -488,6 +496,8 @@ const instantLyrics = computed(() => {
|
||||
}
|
||||
}
|
||||
.play-menu {
|
||||
margin-left: auto;
|
||||
max-width: 640px;
|
||||
.time-container {
|
||||
margin-right: 8px;
|
||||
.n-tag {
|
||||
|
||||
@@ -79,8 +79,9 @@
|
||||
import { useMusicStore, useStatusStore } from "@/stores";
|
||||
import { coverLoaded } from "@/utils/helper";
|
||||
import { debounce, isObject } from "lodash-es";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
|
||||
@@ -146,10 +146,11 @@ const loadMoreComment = () => {
|
||||
watch(
|
||||
() => songId.value,
|
||||
() => {
|
||||
if (!isShowComment.value) {
|
||||
commentData.value = [];
|
||||
return;
|
||||
}
|
||||
commentData.value = [];
|
||||
commentHotData.value = [];
|
||||
commentPage.value = 1;
|
||||
commentHasMore.value = true;
|
||||
if (!isShowComment.value) return;
|
||||
getHotCommentData();
|
||||
getAllComment();
|
||||
},
|
||||
|
||||
@@ -95,8 +95,9 @@ import { useMusicStore, useStatusStore, useDataStore } from "@/stores";
|
||||
import { msToTime } from "@/utils/time";
|
||||
import { openDownloadSong, openPlaylistAdd } from "@/utils/modal";
|
||||
import { toLikeSong } from "@/utils/auth";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div :class="['player-data', settingStore.playerType, { center }]">
|
||||
<div :class="['player-data', settingStore.playerType, { center, light }]">
|
||||
<!-- 名称 -->
|
||||
<div class="name">
|
||||
<span class="name-text text-hidden">{{ musicStore.playSong.name || "未知曲目" }}</span>
|
||||
<!-- 额外信息 -->
|
||||
<div v-if="statusStore.playUblock || musicStore.playSong.pc" class="extra-info">
|
||||
<n-flex
|
||||
v-if="statusStore.playUblock || musicStore.playSong.pc"
|
||||
class="extra-info"
|
||||
align="center"
|
||||
>
|
||||
<n-popover :show-arrow="false" placement="right" raw>
|
||||
<template #trigger>
|
||||
<SvgIcon
|
||||
@@ -21,58 +25,74 @@
|
||||
}}
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
</n-flex>
|
||||
</div>
|
||||
<!-- 别名 -->
|
||||
<span v-if="musicStore.playSong.alia" class="alia text-hidden">
|
||||
{{ musicStore.playSong.alia }}
|
||||
</span>
|
||||
<!-- 歌手 -->
|
||||
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
|
||||
<SvgIcon :depth="3" name="Artist" size="20" />
|
||||
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
|
||||
<n-flex :align="center ? 'center' : undefined" size="small" vertical>
|
||||
<!-- 播放状态 -->
|
||||
<n-flex
|
||||
v-if="settingStore.showPlayMeta && !light"
|
||||
class="play-meta"
|
||||
size="small"
|
||||
align="center"
|
||||
>
|
||||
<!-- 歌词模式 -->
|
||||
<span class="meta-item">{{ lyricMode }}</span>
|
||||
<!-- 是否在线 -->
|
||||
<span class="meta-item">
|
||||
{{ musicStore.playSong.path ? "LOCAL" : "ONLINE" }}
|
||||
</span>
|
||||
</n-flex>
|
||||
<!-- 歌手 -->
|
||||
<div v-if="musicStore.playSong.type !== 'radio'" class="artists">
|
||||
<SvgIcon :depth="3" name="Artist" size="20" />
|
||||
<div v-if="Array.isArray(musicStore.playSong.artists)" class="ar-list">
|
||||
<span
|
||||
v-for="ar in musicStore.playSong.artists"
|
||||
:key="ar.id"
|
||||
class="ar"
|
||||
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
|
||||
>
|
||||
{{ ar.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="ar-list">
|
||||
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="artists">
|
||||
<SvgIcon :depth="3" name="Artist" size="20" />
|
||||
<div class="ar-list">
|
||||
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 专辑 -->
|
||||
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
|
||||
<SvgIcon :depth="3" name="Album" size="20" />
|
||||
<span
|
||||
v-for="ar in musicStore.playSong.artists"
|
||||
:key="ar.id"
|
||||
class="ar"
|
||||
@click="jumpPage({ name: 'artist', query: { id: ar.id } })"
|
||||
v-if="isObject(musicStore.playSong.album)"
|
||||
class="name-text text-hidden"
|
||||
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
|
||||
>
|
||||
{{ ar.name }}
|
||||
{{ musicStore.playSong.album?.name || "未知专辑" }}
|
||||
</span>
|
||||
<span v-else class="name-text text-hidden">
|
||||
{{ musicStore.playSong.album || "未知专辑" }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="ar-list">
|
||||
<span class="ar">{{ musicStore.playSong.artists || "未知艺术家" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="artists">
|
||||
<SvgIcon :depth="3" name="Artist" size="20" />
|
||||
<div class="ar-list">
|
||||
<span class="ar">{{ musicStore.playSong.dj?.creator || "未知艺术家" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 专辑 -->
|
||||
<div v-if="musicStore.playSong.type !== 'radio'" class="album">
|
||||
<SvgIcon :depth="3" name="Album" size="20" />
|
||||
<span
|
||||
v-if="isObject(musicStore.playSong.album)"
|
||||
class="name-text text-hidden"
|
||||
@click="jumpPage({ name: 'album', query: { id: musicStore.playSong.album.id } })"
|
||||
<!-- 电台 -->
|
||||
<div
|
||||
v-if="musicStore.playSong.type === 'radio'"
|
||||
class="dj"
|
||||
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
|
||||
>
|
||||
{{ musicStore.playSong.album?.name || "未知专辑" }}
|
||||
</span>
|
||||
<span v-else class="name-text text-hidden">
|
||||
{{ musicStore.playSong.album || "未知专辑" }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 电台 -->
|
||||
<div
|
||||
v-if="musicStore.playSong.type === 'radio'"
|
||||
class="dj"
|
||||
@click="jumpPage({ name: 'dj', query: { id: musicStore.playSong.dj?.id } })"
|
||||
>
|
||||
<SvgIcon :depth="3" name="Podcast" size="20" />
|
||||
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
|
||||
</div>
|
||||
<SvgIcon :depth="3" name="Podcast" size="20" />
|
||||
<span class="name-text text-hidden">{{ musicStore.playSong.dj?.name || "播客电台" }}</span>
|
||||
</div>
|
||||
</n-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,6 +104,8 @@ import { debounce, isObject } from "lodash-es";
|
||||
defineProps<{
|
||||
center?: boolean;
|
||||
theme?: string;
|
||||
// 少量数据模式
|
||||
light?: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -91,6 +113,15 @@ const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 当前歌词模式
|
||||
const lyricMode = computed(() => {
|
||||
if (settingStore.showYrc) {
|
||||
if (statusStore.usingTTMLLyric) return "TTML";
|
||||
if (musicStore.isHasYrc) return "YRC";
|
||||
}
|
||||
return musicStore.isHasLrc ? "LRC" : "NO-LRC";
|
||||
});
|
||||
|
||||
const jumpPage = debounce(
|
||||
(go: RouteLocationRaw) => {
|
||||
if (!go) return;
|
||||
@@ -134,14 +165,13 @@ const jumpPage = debounce(
|
||||
}
|
||||
}
|
||||
.alia {
|
||||
margin: 6px 0 6px 2px;
|
||||
margin: 6px 0 6px 4px;
|
||||
opacity: 0.6;
|
||||
font-size: 18px;
|
||||
line-clamp: 1;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
.artists {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.n-icon {
|
||||
@@ -178,7 +208,6 @@ const jumpPage = debounce(
|
||||
}
|
||||
.album,
|
||||
.dj {
|
||||
margin-top: 2px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -196,6 +225,16 @@ const jumpPage = debounce(
|
||||
}
|
||||
}
|
||||
}
|
||||
.play-meta {
|
||||
padding: 4px 4px;
|
||||
opacity: 0.6;
|
||||
.meta-item {
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(var(--main-color), 0.6);
|
||||
}
|
||||
}
|
||||
&.record {
|
||||
width: 100%;
|
||||
padding: 0 80px 0 24px;
|
||||
@@ -219,6 +258,20 @@ const jumpPage = debounce(
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
&.light {
|
||||
.name {
|
||||
.name-text {
|
||||
line-clamp: 1;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
.extra-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.alia {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.player-tip {
|
||||
max-width: 240px;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
v-if="!statusStore.personalFmMode"
|
||||
:value="dataStore.playList?.length ?? 0"
|
||||
:show="settingStore.showPlaylistCount"
|
||||
:max="999"
|
||||
:max="9999"
|
||||
:style="{
|
||||
marginRight: settingStore.showPlaylistCount ? '12px' : null,
|
||||
}"
|
||||
@@ -65,8 +65,9 @@ import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/
|
||||
import { openAutoClose, openChangeRate, openEqualizer } from "@/utils/modal";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { renderIcon } from "@/utils/helper";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore } from "@/stores";
|
||||
import { msToTime } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
withDefaults(defineProps<{ showTooltip?: boolean }>(), { showTooltip: true });
|
||||
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 拖动时的临时值
|
||||
|
||||
@@ -39,12 +39,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { searchDefault } from "@/api/search";
|
||||
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { songDetail } from "@/api/song";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
import SearchInpMenu from "@/components/Menu/SearchInpMenu.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
</n-card>
|
||||
</n-collapse-transition>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar"> 社区与资讯 </n-h3>
|
||||
<n-flex class="link">
|
||||
<n-card
|
||||
v-for="(item, index) in communityData"
|
||||
:key="index"
|
||||
class="link-item"
|
||||
hoverable
|
||||
@click="openLink(item.url)"
|
||||
>
|
||||
<SvgIcon :name="item.icon" :size="26" />
|
||||
<n-text class="name"> {{ item.name }} </n-text>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar"> 历史版本 </n-h3>
|
||||
<n-collapse-transition :show="oldVersion?.length > 0">
|
||||
@@ -59,21 +74,6 @@
|
||||
</n-collapse>
|
||||
</n-collapse-transition>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar"> 社区与资讯 </n-h3>
|
||||
<n-flex class="link">
|
||||
<n-card
|
||||
v-for="(item, index) in communityData"
|
||||
:key="index"
|
||||
class="link-item"
|
||||
hoverable
|
||||
@click="openLink(item.url)"
|
||||
>
|
||||
<SvgIcon :name="item.icon" :size="26" />
|
||||
<n-text class="name"> {{ item.name }} </n-text>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -89,6 +89,11 @@ const statusStore = useStatusStore();
|
||||
|
||||
// 社区数据
|
||||
const communityData = [
|
||||
{
|
||||
name: "加入交流群",
|
||||
url: "https://qm.qq.com/cgi-bin/qm/qr?k=2-cVSf1bE0AvAehCib00qFEFdUvPaJ_k&jump_from=webapi&authKey=1NEhib9+GsmsXVo2rCc0IbRaVHeeRXJJ0gbsyKDcIwDdAzYySOubkFCvkV32+7Cw",
|
||||
icon: "QQ",
|
||||
},
|
||||
{
|
||||
name: "GitHub",
|
||||
url: packageJson.github,
|
||||
|
||||
@@ -246,14 +246,12 @@
|
||||
</n-card>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar">
|
||||
歌词内容
|
||||
</n-h3>
|
||||
<n-h3 prefix="bar"> 歌词内容 </n-h3>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">
|
||||
启用在线 TTML 歌词
|
||||
<n-tag type="warning" size="small" round style="display: inline; vertical-align: middle;">Beta</n-tag>
|
||||
<n-tag type="warning" size="small" round> Beta </n-tag>
|
||||
</n-text>
|
||||
<n-text class="tip" :depth="3">
|
||||
是否从 AMLL TTML DB 获取歌词(如有),TTML
|
||||
@@ -545,10 +543,11 @@ import { cloneDeep, isEqual } from "lodash-es";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { openLyricExclude } from "@/utils/modal";
|
||||
import { LyricConfig } from "@/types/desktop-lyric";
|
||||
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { SelectOption } from "naive-ui";
|
||||
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
|
||||
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="setting">
|
||||
<div class="set-left">
|
||||
<div class="title">
|
||||
<n-flex class="title" :size="0" vertical>
|
||||
<n-h1>设置</n-h1>
|
||||
<n-text :depth="3">个性化与全局设置</n-text>
|
||||
</div>
|
||||
</n-flex>
|
||||
<!-- 设置菜单 -->
|
||||
<n-menu
|
||||
v-model:value="activeKey"
|
||||
|
||||
@@ -66,13 +66,6 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.playSongDemo" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card v-if="isElectron" class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">音乐解锁</n-text>
|
||||
<n-text class="tip" :depth="3">在无法正常播放时进行替换,可能会与原曲不符</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card v-if="isElectron" class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">音频输出设备</n-text>
|
||||
@@ -87,6 +80,35 @@
|
||||
/>
|
||||
</n-card>
|
||||
</div>
|
||||
<div v-if="isElectron" class="set-list">
|
||||
<n-h3 prefix="bar">
|
||||
音乐解锁
|
||||
<n-tag type="warning" size="small" round>Beta</n-tag>
|
||||
</n-h3>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">音乐解锁</n-text>
|
||||
<n-text class="tip" :depth="3"> 在无法正常播放时进行替换,可能会与原曲不符 </n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.useSongUnlock" class="set" :round="false" />
|
||||
</n-card>
|
||||
<!-- 音源配置 -->
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">音源配置</n-text>
|
||||
<n-text class="tip" :depth="3"> 配置歌曲解锁的音源顺序或是否启用 </n-text>
|
||||
</div>
|
||||
<n-button
|
||||
:disabled="!settingStore.useSongUnlock"
|
||||
type="primary"
|
||||
strong
|
||||
secondary
|
||||
@click="openSongUnlockManager"
|
||||
>
|
||||
配置
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<div class="set-list">
|
||||
<n-h3 prefix="bar"> 播放器 </n-h3>
|
||||
<n-card class="set-item">
|
||||
@@ -155,6 +177,13 @@
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.barLyricShow" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">展示播放状态信息</n-text>
|
||||
<n-text class="tip" :depth="3">展示当前歌曲及歌词的状态信息</n-text>
|
||||
</div>
|
||||
<n-switch v-model:value="settingStore.showPlayMeta" class="set" :round="false" />
|
||||
</n-card>
|
||||
<n-card class="set-item">
|
||||
<div class="label">
|
||||
<n-text class="name">播放列表歌曲数量</n-text>
|
||||
@@ -220,8 +249,10 @@ import { isLogin } from "@/utils/auth";
|
||||
import { renderOption } from "@/utils/helper";
|
||||
import { isElectron } from "@/utils/env";
|
||||
import { uniqBy } from "lodash";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { openSongUnlockManager } from "@/utils/modal";
|
||||
|
||||
const player = usePlayer();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 输出设备数据
|
||||
|
||||
@@ -106,6 +106,7 @@ export const useMusicStore = defineStore("music", {
|
||||
"play-lyric-change",
|
||||
cloneDeep({
|
||||
songId: this.playSong?.id,
|
||||
lyricLoading: false,
|
||||
lrcData: this.songLyric.lrcData ?? [],
|
||||
yrcData: this.songLyric.yrcData ?? [],
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { keywords, regexes } from "@/assets/data/exclude";
|
||||
import { SongUnlockServer } from "@/utils/songManager";
|
||||
|
||||
export interface SettingState {
|
||||
/** 明暗模式 */
|
||||
@@ -99,6 +100,8 @@ export interface SettingState {
|
||||
songVolumeFadeTime: number;
|
||||
/** 是否使用解灰 */
|
||||
useSongUnlock: boolean;
|
||||
/** 歌曲解锁音源 */
|
||||
songUnlockServer: { key: SongUnlockServer; enabled: boolean }[];
|
||||
/** 显示倒计时 */
|
||||
countDownShow: boolean;
|
||||
/** 显示歌词条 */
|
||||
@@ -169,6 +172,8 @@ export interface SettingState {
|
||||
excludeRegexes: string[];
|
||||
/** 显示默认本地路径 */
|
||||
showDefaultLocalPath: boolean;
|
||||
/** 展示当前歌曲歌词状态信息 */
|
||||
showPlayMeta: boolean;
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore("setting", {
|
||||
@@ -199,6 +204,11 @@ export const useSettingStore = defineStore("setting", {
|
||||
songVolumeFade: true,
|
||||
songVolumeFadeTime: 300,
|
||||
useSongUnlock: true,
|
||||
songUnlockServer: [
|
||||
{ key: SongUnlockServer.BODIAN, enabled: true },
|
||||
{ key: SongUnlockServer.GEQUBAO, enabled: true },
|
||||
{ key: SongUnlockServer.NETEASE, enabled: true },
|
||||
],
|
||||
countDownShow: true,
|
||||
barLyricShow: true,
|
||||
playerType: "cover",
|
||||
@@ -247,6 +257,7 @@ export const useSettingStore = defineStore("setting", {
|
||||
proxyPort: 80,
|
||||
useRealIP: false,
|
||||
realIP: "",
|
||||
showPlayMeta: false,
|
||||
}),
|
||||
getters: {
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-user-drag: none;
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -18,6 +17,12 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
audio {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
2
src/types/desktop-lyric.d.ts
vendored
2
src/types/desktop-lyric.d.ts
vendored
@@ -8,6 +8,8 @@ export interface LyricData {
|
||||
playStatus?: boolean;
|
||||
/** 当前播放进度 */
|
||||
currentTime?: number;
|
||||
/** 是否正在加载歌词 */
|
||||
lyricLoading?: boolean;
|
||||
/** 当前播放歌曲 id(用于偏移校准) */
|
||||
songId?: number;
|
||||
/** 当前歌曲的时间偏移(秒,正负均可) */
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useEventListener } from "@vueuse/core";
|
||||
import { openUserAgreement } from "@/utils/modal";
|
||||
import { debounce } from "lodash-es";
|
||||
import { isElectron } from "./env";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import packageJson from "@/../package.json";
|
||||
import player from "@/utils/player";
|
||||
import log from "./log";
|
||||
|
||||
// 应用初始化时需要执行的操作
|
||||
const init = async () => {
|
||||
// init pinia-data
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
@@ -58,6 +59,7 @@ const initEventListener = () => {
|
||||
|
||||
// 键盘事件
|
||||
const keyDownEvent = debounce((event: KeyboardEvent) => {
|
||||
const player = usePlayer();
|
||||
const shortcutStore = useShortcutStore();
|
||||
const target = event.target as HTMLElement;
|
||||
// 排除元素
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isElectron } from "./env";
|
||||
import { openSetting, openUpdateApp } from "./modal";
|
||||
import { useMusicStore, useDataStore, useStatusStore } from "@/stores";
|
||||
import { toLikeSong } from "./auth";
|
||||
import player from "./player";
|
||||
import { usePlayer } from "./player";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { getPlayerInfo } from "./player-utils/song";
|
||||
import { SettingType } from "@/types/main";
|
||||
@@ -17,6 +17,7 @@ const closeUpdateStatus = () => {
|
||||
const initIpc = () => {
|
||||
try {
|
||||
if (!isElectron) return;
|
||||
const player = usePlayer();
|
||||
// 播放
|
||||
window.electron.ipcRenderer.on("play", () => player.play());
|
||||
// 暂停
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
|
||||
import { songLyric, songLyricTTML } from "@/api/song";
|
||||
import { type SongLyric } from "@/types/lyric";
|
||||
import {
|
||||
type LyricLine,
|
||||
LyricWord,
|
||||
parseLrc,
|
||||
parseTTML,
|
||||
parseYrc,
|
||||
} from "@applemusic-like-lyrics/lyric";
|
||||
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
|
||||
import { isElectron } from "./env";
|
||||
import { isEmpty } from "lodash-es";
|
||||
|
||||
// TODO: 实现歌词统一管理类
|
||||
// 先区分是在线还是本地
|
||||
// 然后检查本地歌词覆盖
|
||||
// 如果本地没有覆盖,进行在线请求
|
||||
// 然后处理并格式化
|
||||
// 然后根据配置的歌词排除内容来处理
|
||||
// 然后写入 store
|
||||
class LyricManager {
|
||||
/**
|
||||
* 在线歌词请求序列
|
||||
@@ -112,65 +99,61 @@ class LyricManager {
|
||||
const req = this.activeLyricReq;
|
||||
// 最终结果
|
||||
const result: SongLyric = { lrcData: [], yrcData: [] };
|
||||
// 是否采用了 TTML
|
||||
let ttmlAdopted = false;
|
||||
// 过期判断
|
||||
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
|
||||
// 处理 TTML 歌词
|
||||
const adoptTTML = async () => {
|
||||
try {
|
||||
if (!settingStore.enableTTMLLyric) return;
|
||||
const ttmlContent = await songLyricTTML(id);
|
||||
if (isStale()) return;
|
||||
if (!ttmlContent || typeof ttmlContent !== "string") return;
|
||||
const parsed = parseTTML(ttmlContent);
|
||||
const lines = parsed?.lines || [];
|
||||
if (!lines.length) return;
|
||||
result.yrcData = lines;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (!settingStore.enableTTMLLyric) return;
|
||||
const ttmlContent = await songLyricTTML(id);
|
||||
if (isStale()) return;
|
||||
if (!ttmlContent || typeof ttmlContent !== "string") return;
|
||||
const parsed = parseTTML(ttmlContent);
|
||||
const lines = parsed?.lines || [];
|
||||
if (!lines.length) return;
|
||||
result.yrcData = lines;
|
||||
ttmlAdopted = true;
|
||||
};
|
||||
// 处理 LRC 歌词
|
||||
const adoptLRC = async () => {
|
||||
try {
|
||||
const data = await songLyric(id);
|
||||
if (isStale()) return;
|
||||
if (!data || data.code !== 200) return;
|
||||
let lrcLines: LyricLine[] = [];
|
||||
let yrcLines: LyricLine[] = [];
|
||||
// 普通歌词
|
||||
if (data?.lrc?.lyric) {
|
||||
lrcLines = parseLrc(data.lrc.lyric) || [];
|
||||
// 普通歌词翻译
|
||||
if (data?.tlyric?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.tlyric.lyric), "translatedLyric");
|
||||
// 普通歌词音译
|
||||
if (data?.romalrc?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.romalrc.lyric), "romanLyric");
|
||||
}
|
||||
// 逐字歌词
|
||||
if (data?.yrc?.lyric) {
|
||||
yrcLines = parseYrc(data.yrc.lyric) || [];
|
||||
// 逐字歌词翻译
|
||||
if (data?.ytlrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.ytlrc.lyric), "translatedLyric");
|
||||
// 逐字歌词音译
|
||||
if (data?.yromalrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
|
||||
}
|
||||
if (lrcLines.length) result.lrcData = lrcLines;
|
||||
// 如果没有 TTML,则采用 网易云 YRC
|
||||
if (!result.yrcData.length && yrcLines.length) {
|
||||
result.yrcData = yrcLines;
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
const data = await songLyric(id);
|
||||
if (isStale()) return;
|
||||
if (!data || data.code !== 200) return;
|
||||
let lrcLines: LyricLine[] = [];
|
||||
let yrcLines: LyricLine[] = [];
|
||||
// 普通歌词
|
||||
if (data?.lrc?.lyric) {
|
||||
lrcLines = parseLrc(data.lrc.lyric) || [];
|
||||
// 普通歌词翻译
|
||||
if (data?.tlyric?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.tlyric.lyric), "translatedLyric");
|
||||
// 普通歌词音译
|
||||
if (data?.romalrc?.lyric)
|
||||
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.romalrc.lyric), "romanLyric");
|
||||
}
|
||||
// 逐字歌词
|
||||
if (data?.yrc?.lyric) {
|
||||
yrcLines = parseYrc(data.yrc.lyric) || [];
|
||||
// 逐字歌词翻译
|
||||
if (data?.ytlrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.ytlrc.lyric), "translatedLyric");
|
||||
// 逐字歌词音译
|
||||
if (data?.yromalrc?.lyric)
|
||||
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
|
||||
}
|
||||
if (lrcLines.length) result.lrcData = lrcLines;
|
||||
// 如果没有 TTML,则采用 网易云 YRC
|
||||
if (!result.yrcData.length && yrcLines.length) {
|
||||
result.yrcData = yrcLines;
|
||||
}
|
||||
// 先返回一次,避免 TTML 请求过慢
|
||||
const lyricData = this.handleLyricExclude(result);
|
||||
this.setFinalLyric(lyricData, req);
|
||||
};
|
||||
// 统一判断与设置 TTML
|
||||
// 设置 TTML
|
||||
await Promise.allSettled([adoptTTML(), adoptLRC()]);
|
||||
statusStore.usingTTMLLyric = Boolean(
|
||||
settingStore.enableTTMLLyric && result.yrcData?.length && !result.lrcData?.length,
|
||||
);
|
||||
statusStore.usingTTMLLyric = ttmlAdopted;
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
@@ -189,19 +172,7 @@ class LyricManager {
|
||||
const ttml = parseTTML(lyric);
|
||||
const lines = ttml?.lines || [];
|
||||
statusStore.usingTTMLLyric = true;
|
||||
// 构成普通歌词
|
||||
const lrcLines: LyricLine[] = lines.map((line) => ({
|
||||
...line,
|
||||
words: [
|
||||
{
|
||||
word: line.words?.map((w) => w.word)?.join("") || "",
|
||||
startTime: line.startTime || 0,
|
||||
endTime: line.endTime || 0,
|
||||
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
|
||||
},
|
||||
] as LyricWord[],
|
||||
}));
|
||||
return { lrcData: lrcLines, yrcData: lines };
|
||||
return { lrcData: [], yrcData: lines };
|
||||
}
|
||||
// 解析本地歌词并对其
|
||||
const lrcLines = parseLrc(lyric);
|
||||
@@ -270,6 +241,7 @@ class LyricManager {
|
||||
* @returns 处理后的歌词数据
|
||||
*/
|
||||
private handleLyricExclude(lyricData: SongLyric): SongLyric {
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
const { enableExcludeLyrics, excludeKeywords, excludeRegexes } = settingStore;
|
||||
// 未开启排除
|
||||
@@ -300,47 +272,90 @@ class LyricManager {
|
||||
const filterLines = (lines: LyricLine[]) => (lines || []).filter((l) => !isExcluded(l));
|
||||
return {
|
||||
lrcData: filterLines(lyricData.lrcData || []),
|
||||
yrcData: filterLines(lyricData.yrcData || []),
|
||||
yrcData:
|
||||
// 若当前为 TTML 且开启排除
|
||||
statusStore.usingTTMLLyric && settingStore.enableExcludeTTML
|
||||
? filterLines(lyricData.yrcData || [])
|
||||
: lyricData.yrcData || [],
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 设置最终歌词
|
||||
* @param lyricData 歌词数据
|
||||
* @param req 当前歌词请求
|
||||
*/
|
||||
private setFinalLyric(lyricData: SongLyric, req: number) {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
// 若非本次
|
||||
if (this.activeLyricReq !== req) return;
|
||||
// 如果只有逐字歌词
|
||||
if (lyricData.lrcData.length === 0 && lyricData.yrcData.length > 0) {
|
||||
// 构成普通歌词
|
||||
lyricData.lrcData = lyricData.yrcData.map((line) => ({
|
||||
...line,
|
||||
words: [
|
||||
{
|
||||
word: line.words?.map((w) => w.word)?.join("") || "",
|
||||
startTime: line.startTime || 0,
|
||||
endTime: line.endTime || 0,
|
||||
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
// 设置歌词
|
||||
musicStore.setSongLyric(lyricData, true);
|
||||
// 结束加载状态
|
||||
statusStore.lyricLoading = false;
|
||||
}
|
||||
/**
|
||||
* 处理歌词
|
||||
* @param id 歌曲 ID
|
||||
* @param path 本地歌词路径(可选)
|
||||
*/
|
||||
public async handleLyric(id: number, path?: string) {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
// 标记当前歌词请求(避免旧请求覆盖新请求)
|
||||
const req = ++this.lyricReqSeq;
|
||||
this.activeLyricReq = req;
|
||||
try {
|
||||
// 歌词加载状态
|
||||
statusStore.lyricLoading = true;
|
||||
// 标记当前歌词请求(避免旧请求覆盖新请求)
|
||||
this.activeLyricReq = ++this.lyricReqSeq;
|
||||
// 通知桌面歌词
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send("update-desktop-lyric-data", {
|
||||
lyricLoading: true,
|
||||
});
|
||||
}
|
||||
// 检查歌词覆盖
|
||||
let lyricData = await this.checkLocalLyricOverride(id);
|
||||
// 开始获取歌词
|
||||
if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) {
|
||||
// 进行本地歌词对齐
|
||||
lyricData = this.alignLocalLyrics(lyricData);
|
||||
// 排除本地歌词内容
|
||||
if (settingStore.enableExcludeLocalLyrics) {
|
||||
lyricData = this.handleLyricExclude(lyricData);
|
||||
}
|
||||
} else if (path) {
|
||||
lyricData = await this.handleLocalLyric(path);
|
||||
// 排除本地歌词内容
|
||||
if (settingStore.enableExcludeLocalLyrics) {
|
||||
lyricData = this.handleLyricExclude(lyricData);
|
||||
}
|
||||
} else {
|
||||
lyricData = await this.handleOnlineLyric(id);
|
||||
// 排除内容
|
||||
lyricData = this.handleLyricExclude(lyricData);
|
||||
}
|
||||
// 排除内容
|
||||
lyricData = this.handleLyricExclude(lyricData);
|
||||
// 设置歌词
|
||||
musicStore.setSongLyric(lyricData, true);
|
||||
console.log("最终歌词数据", lyricData);
|
||||
this.setFinalLyric(lyricData, req);
|
||||
} catch (error) {
|
||||
console.error("❌ 处理歌词失败:", error);
|
||||
// 重置歌词
|
||||
this.resetSongLyric();
|
||||
} finally {
|
||||
// 歌词加载状态
|
||||
if (musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {
|
||||
statusStore.lyricLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ import ExcludeLyrics from "@/components/Modal/ExcludeLyrics.vue";
|
||||
import ChangeRate from "@/components/Modal/ChangeRate.vue";
|
||||
import AutoClose from "@/components/Modal/AutoClose.vue";
|
||||
import Equalizer from "@/components/Modal/Equalizer.vue";
|
||||
import SongUnlockManager from "@/components/Modal/SongUnlockManager.vue";
|
||||
import { NScrollbar } from "naive-ui";
|
||||
|
||||
// 用户协议
|
||||
export const openUserAgreement = () => {
|
||||
@@ -294,3 +296,41 @@ export const openEqualizer = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开简介弹窗
|
||||
* @param content 简介内容
|
||||
*/
|
||||
export const openDescModal = (content: string, title: string = "歌单简介") => {
|
||||
window.$modal.create({
|
||||
preset: "card",
|
||||
transformOrigin: "center",
|
||||
autoFocus: false,
|
||||
style: { width: "600px" },
|
||||
title,
|
||||
content: () => {
|
||||
return h(
|
||||
NScrollbar,
|
||||
{ style: { maxHeight: "400px" } },
|
||||
{
|
||||
default: () =>
|
||||
h("div", { style: { whiteSpace: "pre-wrap" } }, { default: () => content }),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 打开音源管理弹窗 */
|
||||
export const openSongUnlockManager = () => {
|
||||
window.$modal.create({
|
||||
preset: "card",
|
||||
transformOrigin: "center",
|
||||
autoFocus: false,
|
||||
style: { width: "500px" },
|
||||
title: "音源管理",
|
||||
content: () => {
|
||||
return h(SongUnlockManager);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,23 +84,34 @@ export const getUnlockSongUrl = async (songData: SongType): Promise<string | nul
|
||||
const artist = Array.isArray(songData.artists) ? songData.artists[0].name : songData.artists;
|
||||
const keyWord = songData.name + "-" + artist;
|
||||
if (!songId || !keyWord) return null;
|
||||
|
||||
const servers: any[] = [
|
||||
"bodian",
|
||||
"netease",
|
||||
];
|
||||
|
||||
// 尝试解锁
|
||||
const promises = servers.map(server => unlockSongUrl(songId, keyWord, server));
|
||||
const results = await Promise.allSettled(promises);
|
||||
// 解析结果
|
||||
for (const result of results) {
|
||||
if (
|
||||
result.status === "fulfilled" &&
|
||||
result.value.code === 200 &&
|
||||
result.value.url
|
||||
) {
|
||||
return result.value.url;
|
||||
// 获取音源列表
|
||||
const settingStore = useSettingStore();
|
||||
const servers = settingStore.songUnlockServer
|
||||
.filter((server) => server.enabled)
|
||||
.map((server) => server.key);
|
||||
if (servers.length === 0) return null;
|
||||
// 并发请求
|
||||
const promises = servers.map((server) =>
|
||||
unlockSongUrl(songId, keyWord, server)
|
||||
.then((result) => ({
|
||||
server,
|
||||
result,
|
||||
success: result.code === 200 && !!result.url,
|
||||
}))
|
||||
.catch((err) => {
|
||||
console.error(`Unlock failed with server ${server}:`, err);
|
||||
return { server, result: null, success: false };
|
||||
}),
|
||||
);
|
||||
// 按优先级顺序处理结果
|
||||
for (const p of promises) {
|
||||
try {
|
||||
const item = await p;
|
||||
if (item.success && item.result) {
|
||||
return item.result.url;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageReactive } from "naive-ui";
|
||||
import { Howl, Howler } from "howler";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
|
||||
import { useIntervalFn } from "@vueuse/core";
|
||||
import { calculateProgress } from "./time";
|
||||
import { shuffleArray, runIdle } from "./helper";
|
||||
import { heartRateList } from "@/api/playlist";
|
||||
@@ -23,90 +24,25 @@ import audioContextManager from "@/utils/player-utils/context";
|
||||
import lyricManager from "./lyricManager";
|
||||
import blob from "./blob";
|
||||
|
||||
// 播放器核心
|
||||
// Howler.js
|
||||
|
||||
/* *允许播放格式 */
|
||||
const allowPlayFormat = ["mp3", "flac", "webm", "ogg", "wav"];
|
||||
|
||||
/**
|
||||
* 播放器核心
|
||||
* Howler.js 音频库
|
||||
*/
|
||||
class Player {
|
||||
/** 播放器 */
|
||||
private player: Howl;
|
||||
/** 定时器 */
|
||||
private playerInterval: ReturnType<typeof setInterval> | undefined;
|
||||
/** 自动关闭定时器 */
|
||||
private autoCloseInterval: ReturnType<typeof setInterval> | undefined;
|
||||
/** 频谱数据 */
|
||||
private audioContext: AudioContext | null = null;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private dataArray: Uint8Array<ArrayBuffer> | null = null;
|
||||
/** 其他数据 */
|
||||
private message: MessageReactive | null = null;
|
||||
/** 预载下一首歌曲播放地址缓存(仅存 URL,不创建 Howl) */
|
||||
private nextPrefetch: { id: number; url: string | null; ublock: boolean } | null = null;
|
||||
/** 并发控制:当前播放会话与初始化/切曲状态 */
|
||||
private playSessionId: number = 0;
|
||||
/** 是否正在切换歌曲 */
|
||||
private switching: boolean = false;
|
||||
/** 当前曲目重试信息(按歌曲维度计数) */
|
||||
private retryInfo: { songId: number; count: number } = { songId: 0, count: 0 };
|
||||
constructor() {
|
||||
// 创建播放器实例
|
||||
this.player = new Howl({ src: [""], format: allowPlayFormat, autoplay: false });
|
||||
// 初始化媒体会话
|
||||
this.initMediaSession();
|
||||
// 挂载全局
|
||||
window.$player = this;
|
||||
}
|
||||
/**
|
||||
* 新建会话并返回会话 id
|
||||
*/
|
||||
private newSession(): number {
|
||||
this.playSessionId += 1;
|
||||
return this.playSessionId;
|
||||
}
|
||||
/**
|
||||
* 检查传入会话是否过期
|
||||
*/
|
||||
private isStale(sessionId: number): boolean {
|
||||
return sessionId !== this.playSessionId;
|
||||
}
|
||||
/**
|
||||
* 重置底层播放器与定时器(幂等)
|
||||
*/
|
||||
private resetPlayerCore() {
|
||||
try {
|
||||
// 仅卸载当前播放器实例
|
||||
if (this.player) {
|
||||
this.player.stop();
|
||||
this.player.off();
|
||||
this.player.unload();
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
this.cleanupAllTimers();
|
||||
}
|
||||
/**
|
||||
* 处理播放状态
|
||||
*/
|
||||
private handlePlayStatus() {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
const currentSessionId = this.playSessionId;
|
||||
// 清理定时器
|
||||
clearInterval(this.playerInterval);
|
||||
// 更新播放状态
|
||||
this.playerInterval = setInterval(() => {
|
||||
// 检查会话是否过期
|
||||
if (currentSessionId !== this.playSessionId) {
|
||||
clearInterval(this.playerInterval);
|
||||
return;
|
||||
}
|
||||
if (!this.player.playing()) return;
|
||||
private readonly playerInterval = useIntervalFn(
|
||||
() => {
|
||||
if (!this.player?.playing()) return;
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
const currentTime = this.getSeek();
|
||||
const duration = Math.floor(this.player.duration() * 1000);
|
||||
const duration = this.getDuration();
|
||||
// 计算进度条距离
|
||||
const progress = calculateProgress(currentTime, duration);
|
||||
// 计算歌词索引(支持 LRC 与逐字 YRC,对唱重叠处理)
|
||||
@@ -130,7 +66,47 @@ class Player {
|
||||
window.electron.ipcRenderer.send("set-bar", progress);
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
},
|
||||
250,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/** 自动关闭定时器 */
|
||||
private autoCloseInterval: ReturnType<typeof setInterval> | undefined;
|
||||
/** 频谱数据 */
|
||||
private audioContext: AudioContext | null = null;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private dataArray: Uint8Array<ArrayBuffer> | null = null;
|
||||
/** 其他数据 */
|
||||
private message: MessageReactive | null = null;
|
||||
/** 预载下一首歌曲播放地址缓存(仅存 URL,不创建 Howl) */
|
||||
private nextPrefetch: { id: number; url: string | null; ublock: boolean } | null = null;
|
||||
/** 当前曲目重试信息(按歌曲维度计数) */
|
||||
private retryInfo: { songId: number; count: number } = { songId: 0, count: 0 };
|
||||
constructor() {
|
||||
// 创建播放器实例
|
||||
this.player = new Howl({ src: [""], format: allowPlayFormat, autoplay: false });
|
||||
// 初始化媒体会话
|
||||
this.initMediaSession();
|
||||
// 挂载全局
|
||||
window.$player = this;
|
||||
}
|
||||
/**
|
||||
* 重置底层播放器与定时器(幂等)
|
||||
*/
|
||||
private resetPlayerCore() {
|
||||
try {
|
||||
// 仅卸载当前播放器实例
|
||||
if (this.player) {
|
||||
this.player.stop();
|
||||
this.player.off();
|
||||
this.player.unload();
|
||||
}
|
||||
Howler.unload();
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
this.cleanupAllTimers();
|
||||
}
|
||||
/**
|
||||
* 预载下一首歌曲的播放地址(优先官方,失败则并发尝试解灰)
|
||||
@@ -215,19 +191,10 @@ class Player {
|
||||
const settingStore = useSettingStore();
|
||||
// 播放信息
|
||||
const { id, path, type } = musicStore.playSong;
|
||||
const currentSessionId = this.playSessionId;
|
||||
// 检查会话是否过期
|
||||
if (currentSessionId !== this.playSessionId) {
|
||||
console.log("🚫 Session expired, skipping player creation");
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一重置底层播放器
|
||||
this.resetPlayerCore();
|
||||
// 二次检查会话
|
||||
if (currentSessionId !== this.playSessionId) {
|
||||
console.log("🚫 Session expired after cleanup, aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建播放器
|
||||
this.player = new Howl({
|
||||
src,
|
||||
@@ -250,8 +217,7 @@ class Player {
|
||||
// else resetSongLyric();
|
||||
// 获取歌词数据
|
||||
lyricManager.handleLyric(id, path);
|
||||
// 定时获取状态
|
||||
if (!this.playerInterval) this.handlePlayStatus();
|
||||
|
||||
// 新增播放历史
|
||||
if (type !== "radio") dataStore.setHistory(musicStore.playSong);
|
||||
// 获取歌曲封面主色
|
||||
@@ -279,10 +245,8 @@ class Player {
|
||||
const playSongData = getPlaySongData();
|
||||
// 获取配置
|
||||
const { seek } = options;
|
||||
const currentSessionId = this.playSessionId;
|
||||
// 初次加载
|
||||
this.player.once("load", () => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
// 允许跨域
|
||||
if (settingStore.showSpectrums) {
|
||||
const audioDom = this.getAudioDom();
|
||||
@@ -298,7 +262,7 @@ class Player {
|
||||
}
|
||||
// 恢复进度(仅在明确指定且大于0时才恢复,避免切换歌曲时意外恢复进度)
|
||||
if (seek && seek > 0) {
|
||||
const duration = Math.floor(this.player.duration() * 1000);
|
||||
const duration = this.getDuration();
|
||||
// 确保恢复的进度有效且距离歌曲结束大于2秒
|
||||
if (duration && seek < duration - 2000) {
|
||||
this.setSeek(seek);
|
||||
@@ -325,8 +289,8 @@ class Player {
|
||||
});
|
||||
// 播放
|
||||
this.player.on("play", () => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
window.document.title = getPlayerInfo() || "SPlayer";
|
||||
this.playerInterval.resume();
|
||||
// 重置重试计数
|
||||
try {
|
||||
const current = getPlaySongData();
|
||||
@@ -344,8 +308,8 @@ class Player {
|
||||
});
|
||||
// 暂停
|
||||
this.player.on("pause", () => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
if (!isElectron) window.document.title = "SPlayer";
|
||||
this.playerInterval.pause();
|
||||
// ipc
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send("play-status-change", false);
|
||||
@@ -354,7 +318,7 @@ class Player {
|
||||
});
|
||||
// 结束
|
||||
this.player.on("end", () => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
this.playerInterval.pause();
|
||||
// statusStore.playStatus = false;
|
||||
console.log("⏹️ song end:", playSongData);
|
||||
|
||||
@@ -374,13 +338,11 @@ class Player {
|
||||
});
|
||||
// 错误
|
||||
this.player.on("loaderror", (sourceid, err: unknown) => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
const code = typeof err === "number" ? err : undefined;
|
||||
this.handlePlaybackError(code);
|
||||
console.error("❌ song error:", sourceid, playSongData, err);
|
||||
});
|
||||
this.player.on("playerror", (sourceid, err: unknown) => {
|
||||
if (currentSessionId !== this.playSessionId) return;
|
||||
const code = typeof err === "number" ? err : undefined;
|
||||
this.handlePlaybackError(code);
|
||||
console.error("❌ song play error:", sourceid, playSongData, err);
|
||||
@@ -588,7 +550,6 @@ class Player {
|
||||
const musicStore = useMusicStore();
|
||||
const statusStore = useStatusStore();
|
||||
const settingStore = useSettingStore();
|
||||
const sessionId = this.newSession();
|
||||
|
||||
try {
|
||||
// 获取播放数据
|
||||
@@ -608,7 +569,6 @@ class Player {
|
||||
|
||||
// 本地歌曲
|
||||
if (path) {
|
||||
if (this.isStale(sessionId)) return;
|
||||
try {
|
||||
await this.createPlayer(`file://${path}`, autoPlay, seek);
|
||||
await this.parseLocalMusicInfo(path);
|
||||
@@ -657,20 +617,18 @@ class Player {
|
||||
|
||||
if (!playerUrl) {
|
||||
window.$message.error("该歌曲暂无音源,跳至下一首");
|
||||
this.switching = false;
|
||||
await this.nextOrPrev("next");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ 获取歌曲地址出错:", err);
|
||||
window.$message.error("获取歌曲地址失败,跳至下一首");
|
||||
this.switching = false;
|
||||
await this.nextOrPrev("next");
|
||||
return;
|
||||
}
|
||||
|
||||
// 有有效 URL 才创建播放器
|
||||
if (playerUrl && !this.isStale(sessionId)) {
|
||||
if (playerUrl) {
|
||||
try {
|
||||
await this.createPlayer(playerUrl, autoPlay, seek);
|
||||
} catch (err) {
|
||||
@@ -681,10 +639,7 @@ class Player {
|
||||
} catch (err) {
|
||||
console.error("❌ 初始化音乐播放器出错:", err);
|
||||
window.$message.error("播放遇到错误,尝试下一首");
|
||||
this.switching = false;
|
||||
await this.nextOrPrev("next");
|
||||
} finally {
|
||||
this.switching = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -724,7 +679,7 @@ class Player {
|
||||
|
||||
// 播放器未加载完成或不存在
|
||||
if (!this.player || this.player.state() !== "loaded") {
|
||||
if (changeStatus) statusStore.playStatus = false;
|
||||
window.$message.warning("播放器未加载完成,请稍后重试");
|
||||
return;
|
||||
}
|
||||
// 立即设置播放状态
|
||||
@@ -757,12 +712,6 @@ class Player {
|
||||
const dataStore = useDataStore();
|
||||
const musicStore = useMusicStore();
|
||||
try {
|
||||
if (this.switching) {
|
||||
console.log("🔄 Already switching, ignoring request");
|
||||
return;
|
||||
}
|
||||
this.switching = true;
|
||||
|
||||
// 立即更新UI状态,防止用户重复点击
|
||||
statusStore.playLoading = true;
|
||||
statusStore.playStatus = false;
|
||||
@@ -817,16 +766,12 @@ class Player {
|
||||
// 重置播放进度(切换歌曲时必须重置)
|
||||
statusStore.currentTime = 0;
|
||||
statusStore.progress = 0;
|
||||
// 暂停当前播放
|
||||
await this.pause(false);
|
||||
// 初始化播放器(不传入seek参数,确保从头开始播放)
|
||||
await this.initPlayer(play, 0);
|
||||
} catch (error) {
|
||||
console.error("Error in nextOrPrev:", error);
|
||||
statusStore.playLoading = false;
|
||||
throw error;
|
||||
} finally {
|
||||
this.switching = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -914,6 +859,10 @@ class Player {
|
||||
console.warn("⚠️ Player not ready for seek");
|
||||
return;
|
||||
}
|
||||
if (time < 0 || time > this.getDuration()) {
|
||||
console.warn("⚠️ Invalid seek time", time);
|
||||
time = Math.max(0, Math.min(time, this.getDuration()));
|
||||
}
|
||||
this.player.seek(time / 1000);
|
||||
statusStore.currentTime = time;
|
||||
}
|
||||
@@ -926,6 +875,13 @@ class Player {
|
||||
if (!this.player || this.player.state() !== "loaded") return 0;
|
||||
return Math.floor(this.player.seek() * 1000);
|
||||
}
|
||||
/**
|
||||
* 获取播放时长
|
||||
* @returns 播放时长(单位:毫秒)
|
||||
*/
|
||||
getDuration(): number {
|
||||
return Math.floor(this.player.duration() * 1000);
|
||||
}
|
||||
/**
|
||||
* 设置播放速率
|
||||
* @param rate 播放速率
|
||||
@@ -1034,7 +990,6 @@ class Player {
|
||||
// 查找索引(在处理后的列表中查找)
|
||||
statusStore.playIndex = processedData.findIndex((item) => item.id === song.id);
|
||||
// 播放
|
||||
await this.pause(false);
|
||||
await this.initPlayer();
|
||||
}
|
||||
} else {
|
||||
@@ -1043,7 +998,6 @@ class Player {
|
||||
? Math.floor(Math.random() * processedData.length)
|
||||
: 0;
|
||||
// 播放
|
||||
await this.pause(false);
|
||||
await this.initPlayer();
|
||||
}
|
||||
// 更改播放歌单
|
||||
@@ -1083,11 +1037,6 @@ class Player {
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
try {
|
||||
if (this.switching) {
|
||||
console.log("🔄 Already switching, ignoring request");
|
||||
return;
|
||||
}
|
||||
this.switching = true;
|
||||
// 立即更新UI状态,防止用户重复点击
|
||||
statusStore.playLoading = true;
|
||||
statusStore.playStatus = false;
|
||||
@@ -1106,8 +1055,6 @@ class Player {
|
||||
statusStore.currentTime = 0;
|
||||
statusStore.progress = 0;
|
||||
statusStore.lyricIndex = -1;
|
||||
// 暂停当前播放
|
||||
await this.pause(false);
|
||||
// 清理定时器,防止旧定时器继续运行
|
||||
this.cleanupAllTimers();
|
||||
// 清理并播放(不传入seek参数,确保从头开始播放)
|
||||
@@ -1116,8 +1063,6 @@ class Player {
|
||||
console.error("Error in togglePlayIndex:", error);
|
||||
statusStore.playLoading = false;
|
||||
throw error;
|
||||
} finally {
|
||||
this.switching = false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -1457,9 +1402,8 @@ class Player {
|
||||
*/
|
||||
private cleanupAllTimers() {
|
||||
// 清理播放状态定时器
|
||||
if (this.playerInterval) {
|
||||
clearInterval(this.playerInterval);
|
||||
this.playerInterval = undefined;
|
||||
if (this.playerInterval.isActive.value) {
|
||||
this.playerInterval.pause();
|
||||
}
|
||||
// 清理自动关闭定时器
|
||||
if (this.autoCloseInterval) {
|
||||
@@ -1482,4 +1426,15 @@ class Player {
|
||||
}
|
||||
}
|
||||
|
||||
export default new Player();
|
||||
// export default new Player();
|
||||
|
||||
let _player: Player | null = null;
|
||||
|
||||
/**
|
||||
* 获取播放器实例
|
||||
* @returns Player
|
||||
*/
|
||||
export const usePlayer = (): Player => {
|
||||
if (!_player) _player = new Player();
|
||||
return _player;
|
||||
};
|
||||
|
||||
13
src/utils/songManager.ts
Normal file
13
src/utils/songManager.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 歌曲解锁服务器
|
||||
*/
|
||||
export enum SongUnlockServer {
|
||||
NETEASE = "netease",
|
||||
BODIAN = "bodian",
|
||||
// KUWO = "kuwo",
|
||||
GEQUBAO = "gequbao",
|
||||
}
|
||||
|
||||
class SongManager {}
|
||||
|
||||
export default new SongManager();
|
||||
@@ -16,7 +16,7 @@ import { artistAllSongs } from "@/api/artist";
|
||||
import { songDetail } from "@/api/song";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
import { debounce } from "lodash-es";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const props = defineProps<{
|
||||
id: number;
|
||||
@@ -26,6 +26,8 @@ const emit = defineEmits<{
|
||||
scroll: [e: Event];
|
||||
}>();
|
||||
|
||||
const player = usePlayer();
|
||||
|
||||
// 歌曲数据
|
||||
const loading = ref<boolean>(true);
|
||||
const hasMore = ref<boolean>(true);
|
||||
|
||||
@@ -97,8 +97,9 @@ import { userCloud } from "@/api/cloud";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
import { fuzzySearch, renderIcon } from "@/utils/helper";
|
||||
import { openBatchList } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const router = useRouter();
|
||||
const dataStore = useDataStore();
|
||||
|
||||
|
||||
@@ -54,8 +54,9 @@ import { updateDailySongsData } from "@/utils/auth";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
import { renderIcon } from "@/utils/helper";
|
||||
import { openBatchList } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const musicStore = useMusicStore();
|
||||
|
||||
// 更新日期
|
||||
|
||||
@@ -89,12 +89,16 @@
|
||||
'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
|
||||
}"
|
||||
>
|
||||
<span class="word" :style="{ color: lyricConfig.unplayedColor }">
|
||||
{{ text.word }}
|
||||
</span>
|
||||
<span
|
||||
class="filler"
|
||||
:style="[{ color: lyricConfig.playedColor }, getYrcStyle(text, line.index)]"
|
||||
class="word"
|
||||
:style="[
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, ${lyricConfig.playedColor} 50%, ${lyricConfig.unplayedColor} 50%)`,
|
||||
textShadow: 'none',
|
||||
filter: `drop-shadow(0 0 1px ${lyricConfig.shadowColor}) drop-shadow(0 0 2px ${lyricConfig.shadowColor})`,
|
||||
},
|
||||
getYrcStyle(text, line.index),
|
||||
]"
|
||||
>
|
||||
{{ text.word }}
|
||||
</span>
|
||||
@@ -121,7 +125,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from "@vueuse/core";
|
||||
import { useRafFn, useTimeoutFn, useThrottleFn } from "@vueuse/core";
|
||||
import { LyricLine, LyricWord } from "@applemusic-like-lyrics/lyric";
|
||||
import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric";
|
||||
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
|
||||
@@ -131,6 +135,7 @@ const lyricData = reactive<LyricData>({
|
||||
playName: "未知歌曲",
|
||||
playStatus: false,
|
||||
currentTime: 0,
|
||||
lyricLoading: false,
|
||||
songId: 0,
|
||||
songOffset: 0,
|
||||
lrcData: [],
|
||||
@@ -164,7 +169,14 @@ const desktopLyricRef = ref<HTMLElement>();
|
||||
|
||||
// hover 状态控制
|
||||
const isHovered = ref<boolean>(false);
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const { start: startHoverTimer } = useTimeoutFn(
|
||||
() => {
|
||||
isHovered.value = false;
|
||||
},
|
||||
1000,
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* 处理鼠标移动,更新 hover 状态
|
||||
@@ -172,16 +184,7 @@ let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleMouseMove = () => {
|
||||
// 设置 hover 状态(锁定和非锁定状态都响应)
|
||||
isHovered.value = true;
|
||||
// 清除之前的定时器
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
// 设置新的定时器,延迟后移除 hover 状态
|
||||
hoverTimer = setTimeout(() => {
|
||||
isHovered.value = false;
|
||||
hoverTimer = null;
|
||||
}, 1000);
|
||||
startHoverTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -210,43 +213,33 @@ const getSafeEndTime = (lyrics: LyricLine[], idx: number) => {
|
||||
*/
|
||||
const renderLyricLines = computed<RenderLine[]>(() => {
|
||||
const lyrics = lyricData?.yrcData?.length ? lyricData.yrcData : lyricData.lrcData;
|
||||
if (!lyrics?.length) {
|
||||
return [
|
||||
{
|
||||
line: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
words: [{ word: "纯音乐,请欣赏", startTime: 0, endTime: 0, romanWord: "" }],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
index: -1,
|
||||
key: "placeholder",
|
||||
active: true,
|
||||
// 提示词占位
|
||||
const placeholder = (word: string): RenderLine[] => [
|
||||
{
|
||||
line: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
words: [{ word, startTime: 0, endTime: 0, romanWord: "" }],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
index: -1,
|
||||
key: "placeholder",
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
// 加载中
|
||||
if (lyricData.lyricLoading) return placeholder("歌词加载中...");
|
||||
// 纯音乐
|
||||
if (!lyrics?.length) return placeholder("纯音乐,请欣赏");
|
||||
// 获取当前歌词索引
|
||||
const idx = lyricData?.lyricIndex ?? -1;
|
||||
// 索引小于 0,显示歌曲名称
|
||||
if (idx < 0) {
|
||||
const text = lyricData.playName ?? "未知歌曲";
|
||||
return [
|
||||
{
|
||||
line: {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
words: [{ word: text, startTime: 0, endTime: 0, romanWord: "" }],
|
||||
translatedLyric: "",
|
||||
romanLyric: "",
|
||||
isBG: false,
|
||||
isDuet: false,
|
||||
},
|
||||
index: -1,
|
||||
key: "placeholder",
|
||||
active: true,
|
||||
},
|
||||
];
|
||||
return placeholder(text);
|
||||
}
|
||||
const current = lyrics[idx];
|
||||
const next = lyrics[idx + 1];
|
||||
@@ -334,7 +327,7 @@ const renderLyricLines = computed<RenderLine[]>(() => {
|
||||
*/
|
||||
const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
|
||||
const currentLine = lyricData.yrcData?.[lyricIndex];
|
||||
if (!currentLine) return { WebkitMaskPositionX: "100%" };
|
||||
if (!currentLine) return { backgroundPositionX: "100%" };
|
||||
const seekSec = playSeekMs.value;
|
||||
const startSec = currentLine.startTime || 0;
|
||||
const endSec = currentLine.endTime || 0;
|
||||
@@ -343,14 +336,12 @@ const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
|
||||
|
||||
if (!isLineActive) {
|
||||
const hasPlayed = seekSec >= (wordData.endTime || 0);
|
||||
return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" };
|
||||
return { backgroundPositionX: hasPlayed ? "0%" : "100%" };
|
||||
}
|
||||
const durationSec = Math.max((wordData.endTime || 0) - (wordData.startTime || 0), 0.001);
|
||||
const progress = Math.max(Math.min((seekSec - (wordData.startTime || 0)) / durationSec, 1), 0);
|
||||
return {
|
||||
transitionDuration: `0s, 0s, 0.35s`,
|
||||
transitionDelay: `0ms`,
|
||||
WebkitMaskPositionX: `${100 - progress * 100}%`,
|
||||
backgroundPositionX: `${100 - progress * 100}%`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -409,6 +400,11 @@ const dragState = reactive({
|
||||
startWinY: 0,
|
||||
winWidth: 0,
|
||||
winHeight: 0,
|
||||
// 缓存屏幕边界
|
||||
minX: -99999,
|
||||
minY: -99999,
|
||||
maxX: 99999,
|
||||
maxY: 99999,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -436,6 +432,14 @@ const startDrag = async (event: MouseEvent) => {
|
||||
const { width, height } = await window.api.store.get("lyric");
|
||||
const safeWidth = Number(width) > 0 ? Number(width) : 800;
|
||||
const safeHeight = Number(height) > 0 ? Number(height) : 136;
|
||||
// 如果开启了限制边界,在拖拽开始时预先获取一次屏幕范围
|
||||
if (lyricConfig.limitBounds) {
|
||||
const bounds = await window.electron.ipcRenderer.invoke("get-virtual-screen-bounds");
|
||||
dragState.minX = bounds.minX ?? -99999;
|
||||
dragState.minY = bounds.minY ?? -99999;
|
||||
dragState.maxX = bounds.maxX ?? 99999;
|
||||
dragState.maxY = bounds.maxY ?? 99999;
|
||||
}
|
||||
window.electron.ipcRenderer.send("toggle-fixed-max-size", {
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
@@ -456,19 +460,20 @@ const startDrag = async (event: MouseEvent) => {
|
||||
* 桌面歌词拖动移动
|
||||
* @param event 鼠标事件
|
||||
*/
|
||||
const onDocMouseMove = async (event: MouseEvent) => {
|
||||
const onDocMouseMove = useThrottleFn((event: MouseEvent) => {
|
||||
if (!dragState.isDragging || lyricConfig.isLock) return;
|
||||
const screenX = event?.screenX ?? 0;
|
||||
const screenY = event?.screenY ?? 0;
|
||||
let newWinX = Math.round(dragState.startWinX + (screenX - dragState.startX));
|
||||
let newWinY = Math.round(dragState.startWinY + (screenY - dragState.startY));
|
||||
// 是否限制在屏幕边界(支持多屏)
|
||||
// 是否限制在屏幕边界(支持多屏)- 使用缓存的边界数据同步计算
|
||||
if (lyricConfig.limitBounds) {
|
||||
const { minX, minY, maxX, maxY } = await window.electron.ipcRenderer.invoke(
|
||||
"get-virtual-screen-bounds",
|
||||
newWinX = Math.round(
|
||||
Math.max(dragState.minX, Math.min(dragState.maxX - dragState.winWidth, newWinX)),
|
||||
);
|
||||
newWinY = Math.round(
|
||||
Math.max(dragState.minY, Math.min(dragState.maxY - dragState.winHeight, newWinY)),
|
||||
);
|
||||
newWinX = Math.round(Math.max(minX as number, Math.min(maxX - dragState.winWidth, newWinX)));
|
||||
newWinY = Math.round(Math.max(minY as number, Math.min(maxY - dragState.winHeight, newWinY)));
|
||||
}
|
||||
window.electron.ipcRenderer.send(
|
||||
"move-window",
|
||||
@@ -477,7 +482,7 @@ const onDocMouseMove = async (event: MouseEvent) => {
|
||||
dragState.winWidth,
|
||||
dragState.winHeight,
|
||||
);
|
||||
};
|
||||
}, 16);
|
||||
|
||||
/**
|
||||
* 桌面歌词拖动结束
|
||||
@@ -650,11 +655,6 @@ onBeforeUnmount(() => {
|
||||
// 解绑事件
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
// 清理定时器
|
||||
if (hoverTimer) {
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
}
|
||||
if (dragState.isDragging) onDocMouseUp();
|
||||
});
|
||||
</script>
|
||||
@@ -739,6 +739,7 @@ onBeforeUnmount(() => {
|
||||
.lyric-line {
|
||||
width: 100%;
|
||||
line-height: normal;
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -761,34 +762,14 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
.word {
|
||||
opacity: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
.filler {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: -webkit-mask-position-x, transform, opacity;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
rgba(0, 0, 0, 0) 54.5454545455%
|
||||
);
|
||||
mask-size: 220% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
rgb(0, 0, 0) 45.4545454545%,
|
||||
rgba(0, 0, 0, 0) 54.5454545455%
|
||||
);
|
||||
-webkit-mask-size: 220% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
filter 0.3s,
|
||||
margin 0.3s,
|
||||
padding 0.3s !important;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: 100%;
|
||||
will-change: background-position-x;
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 5vh;
|
||||
@@ -797,16 +778,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
.content-text {
|
||||
.filler {
|
||||
opacity: 1;
|
||||
-webkit-mask-position-x: 0%;
|
||||
transition-property: -webkit-mask-position-x, transform, opacity;
|
||||
transition-timing-function: linear, ease, ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&.center {
|
||||
|
||||
@@ -58,8 +58,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDataStore } from "@/stores";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
|
||||
// 清空最近播放
|
||||
|
||||
@@ -34,17 +34,13 @@
|
||||
</n-h2>
|
||||
<n-collapse-transition :show="!listScrolling" class="collapse">
|
||||
<!-- 简介 -->
|
||||
<n-ellipsis
|
||||
<n-text
|
||||
v-if="albumDetailData.description"
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
trigger: 'click',
|
||||
placement: 'bottom',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="description text-hidden"
|
||||
@click="openDescModal(albumDetailData.description, '专辑简介')"
|
||||
>
|
||||
{{ albumDetailData.description }}
|
||||
</n-ellipsis>
|
||||
</n-text>
|
||||
<!-- 信息 -->
|
||||
<n-flex class="meta">
|
||||
<div class="item">
|
||||
@@ -184,11 +180,12 @@ import { renderToolbar } from "@/utils/meta";
|
||||
import { useDataStore, useStatusStore } from "@/stores";
|
||||
import { debounce } from "lodash-es";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
import { openJumpArtist } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
import { openDescModal, openJumpArtist } from "@/utils/modal";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { toLikeAlbum } from "@/utils/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -379,7 +376,7 @@ onMounted(() => {
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
:deep(.n-ellipsis) {
|
||||
.description {
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -37,17 +37,13 @@
|
||||
<n-h2 class="name text-hidden"> 我喜欢的音乐 </n-h2>
|
||||
<n-collapse-transition :show="!listScrolling" class="collapse">
|
||||
<!-- 简介 -->
|
||||
<n-ellipsis
|
||||
<n-text
|
||||
v-if="playlistDetailData.description"
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
trigger: 'click',
|
||||
placement: 'bottom',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="description text-hidden"
|
||||
@click="openDescModal(playlistDetailData.description)"
|
||||
>
|
||||
{{ playlistDetailData.description }}
|
||||
</n-ellipsis>
|
||||
</n-text>
|
||||
<!-- 信息 -->
|
||||
<n-flex class="meta">
|
||||
<div class="item">
|
||||
@@ -58,7 +54,7 @@
|
||||
<SvgIcon name="Update" :depth="3" />
|
||||
<n-text>{{ formatTimestamp(playlistDetailData.updateTime) }}</n-text>
|
||||
</div>
|
||||
<div v-else-if="playlistDetailData.createTime" class="item">
|
||||
<div v-if="playlistDetailData.createTime" class="item">
|
||||
<SvgIcon name="Time" :depth="3" />
|
||||
<n-text>{{ formatTimestamp(playlistDetailData.createTime) }}</n-text>
|
||||
</div>
|
||||
@@ -177,12 +173,13 @@ import { coverLoaded, formatNumber, fuzzySearch, renderIcon } from "@/utils/help
|
||||
import { renderToolbar } from "@/utils/meta";
|
||||
import { debounce, isObject, uniqBy } from "lodash-es";
|
||||
import { useDataStore, useStatusStore } from "@/stores";
|
||||
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";
|
||||
import { openBatchList, openDescModal, openUpdatePlaylist } from "@/utils/modal";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
import { isLogin, updateUserLikePlaylist } from "@/utils/auth";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -541,7 +538,7 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
:deep(.n-ellipsis) {
|
||||
.description {
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -50,17 +50,13 @@
|
||||
</n-h2>
|
||||
<n-collapse-transition :show="!listScrolling" class="collapse">
|
||||
<!-- 简介 -->
|
||||
<n-ellipsis
|
||||
<n-text
|
||||
v-if="playlistDetailData.description"
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
trigger: 'click',
|
||||
placement: 'bottom',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="description text-hidden"
|
||||
@click.stop="openDescModal(playlistDetailData.description)"
|
||||
>
|
||||
{{ playlistDetailData.description }}
|
||||
</n-ellipsis>
|
||||
</n-text>
|
||||
<!-- 信息 -->
|
||||
<n-flex class="meta">
|
||||
<div class="item">
|
||||
@@ -75,7 +71,7 @@
|
||||
<SvgIcon name="Update" :depth="3" />
|
||||
<n-text>{{ formatTimestamp(playlistDetailData.updateTime) }}</n-text>
|
||||
</div>
|
||||
<div v-else-if="playlistDetailData.createTime" class="item">
|
||||
<div v-if="playlistDetailData.createTime" class="item">
|
||||
<SvgIcon name="Time" :depth="3" />
|
||||
<n-text>{{ formatTimestamp(playlistDetailData.createTime) }}</n-text>
|
||||
</div>
|
||||
@@ -228,11 +224,12 @@ import { renderToolbar } from "@/utils/meta";
|
||||
import { isLogin, toLikePlaylist, updateUserLikePlaylist } from "@/utils/auth";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useDataStore, useStatusStore } from "@/stores";
|
||||
import { openBatchList, openUpdatePlaylist } from "@/utils/modal";
|
||||
import { openBatchList, openDescModal, openUpdatePlaylist } from "@/utils/modal";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -632,7 +629,7 @@ onMounted(() => getPlaylistDetail(playlistId.value));
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
:deep(.n-ellipsis) {
|
||||
.description {
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -32,17 +32,13 @@
|
||||
</n-h2>
|
||||
<n-collapse-transition :show="!listScrolling" class="collapse">
|
||||
<!-- 简介 -->
|
||||
<n-ellipsis
|
||||
<n-text
|
||||
v-if="radioDetailData.description"
|
||||
:line-clamp="1"
|
||||
:tooltip="{
|
||||
trigger: 'click',
|
||||
placement: 'bottom',
|
||||
width: 'trigger',
|
||||
}"
|
||||
class="description text-hidden"
|
||||
@click="openDescModal(radioDetailData.description, '节目简介')"
|
||||
>
|
||||
{{ radioDetailData.description }}
|
||||
</n-ellipsis>
|
||||
</n-text>
|
||||
<!-- 信息 -->
|
||||
<n-flex class="meta">
|
||||
<div class="item">
|
||||
@@ -183,11 +179,13 @@ import { renderToolbar } from "@/utils/meta";
|
||||
import { debounce } from "lodash-es";
|
||||
import { useDataStore, useStatusStore } from "@/stores";
|
||||
import { radioAllProgram, radioDetail } from "@/api/radio";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
import { toSubRadio } from "@/utils/auth";
|
||||
import { openDescModal } from "@/utils/modal";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const dataStore = useDataStore();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
@@ -457,7 +455,7 @@ onMounted(() => getRadioDetail(radioId.value));
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
:deep(.n-ellipsis) {
|
||||
.description {
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
<SvgIcon :size="20" name="Folder" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-button quaternary @click="changeLocalLyricPath(index)">
|
||||
<n-button quaternary @click="changeLocalMusicPath(index)">
|
||||
<template #icon>
|
||||
<SvgIcon :size="20" name="Delete" />
|
||||
</template>
|
||||
@@ -131,7 +131,7 @@
|
||||
</n-list>
|
||||
<template #footer>
|
||||
<n-flex justify="center">
|
||||
<n-button class="add-path" strong secondary @click="changeLocalLyricPath()">
|
||||
<n-button class="add-path" strong secondary @click="changeLocalMusicPath()">
|
||||
<template #icon>
|
||||
<SvgIcon name="FolderPlus" />
|
||||
</template>
|
||||
@@ -149,11 +149,12 @@ import type { DropdownOption, MessageReactive } from "naive-ui";
|
||||
import { useLocalStore, useSettingStore } from "@/stores";
|
||||
import { formatSongsList } from "@/utils/format";
|
||||
import { uniqBy, flattenDeep, debounce } from "lodash-es";
|
||||
import { changeLocalLyricPath, fuzzySearch, renderIcon } from "@/utils/helper";
|
||||
import { changeLocalMusicPath, fuzzySearch, renderIcon } from "@/utils/helper";
|
||||
import { openBatchList } from "@/utils/modal";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const localStore = useLocalStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :key="searchKeyword" class="search">
|
||||
<div class="search">
|
||||
<div class="title">
|
||||
<n-text class="keyword">{{ searchKeyword }}</n-text>
|
||||
<n-text depth="3">的相关搜索</n-text>
|
||||
@@ -17,9 +17,20 @@
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition :name="`router-${settingStore.routeAnimation}`" mode="out-in">
|
||||
<KeepAlive v-if="settingStore.useKeepAlive">
|
||||
<component :is="Component" :keyword="searchKeyword" class="router-view" />
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
:keyword="searchKeyword"
|
||||
class="router-view"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component v-else :is="Component" :keyword="searchKeyword" class="router-view" />
|
||||
<component
|
||||
v-else
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
:keyword="searchKeyword"
|
||||
class="router-view"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
@@ -28,14 +39,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from "@/stores";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = computed(() => router.currentRoute.value.query.keyword as string);
|
||||
const searchKeyword = computed(() => route.query.keyword as string);
|
||||
|
||||
// 搜索分类
|
||||
const searchType = ref<string>((router.currentRoute.value?.name as string) || "search-songs");
|
||||
const searchType = ref<string>("search-songs");
|
||||
|
||||
// Tabs 改变
|
||||
const tabChange = (value: string) => {
|
||||
@@ -47,10 +59,16 @@ const tabChange = (value: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeRouteUpdate((to) => {
|
||||
if (to.matched[0].name !== "search") return;
|
||||
searchType.value = to.name as string;
|
||||
});
|
||||
// 监听路由变化,同步 Tab 状态
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
if (name && name.toString().startsWith("search-")) {
|
||||
searchType.value = name as string;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -132,13 +132,14 @@ import { formatCommentList, formatCoverList } from "@/utils/format";
|
||||
import { isArray, isEmpty } from "lodash-es";
|
||||
import { formatNumber } from "@/utils/helper";
|
||||
import { getComment } from "@/api/comment";
|
||||
import player from "@/utils/player";
|
||||
import { usePlayer } from "@/utils/player";
|
||||
// Plyr
|
||||
import Plyr from "plyr";
|
||||
import "plyr/dist/plyr.css";
|
||||
import { formatTimestamp } from "@/utils/time";
|
||||
|
||||
const router = useRouter();
|
||||
const player = usePlayer();
|
||||
const statusStore = useStatusStore();
|
||||
|
||||
// 是否激活
|
||||
|
||||
Reference in New Issue
Block a user