Compare commits
1 Commits
feature/re
...
main
Author | SHA1 | Date | |
---|---|---|---|
1bd0073e24 |
14
.env.example
14
.env.example
|
@ -1,14 +0,0 @@
|
||||||
# Debug Configuration
|
|
||||||
# Set DEBUG environment variable to control debug output
|
|
||||||
# Examples:
|
|
||||||
# DEBUG=msr:* # Enable all MSR debug output
|
|
||||||
# DEBUG=msr:player # Enable only player debug
|
|
||||||
# DEBUG=msr:store,msr:api # Enable store and API debug
|
|
||||||
# DEBUG=* # Enable all debug output (including libraries)
|
|
||||||
# DEBUG= # Disable all debug output
|
|
||||||
|
|
||||||
# Development (default: enable all msr:* debug)
|
|
||||||
VITE_DEBUG=msr:*
|
|
||||||
|
|
||||||
# Production (default: disabled)
|
|
||||||
# VITE_DEBUG=
|
|
44
package-lock.json
generated
44
package-lock.json
generated
|
@ -21,11 +21,9 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@types/chrome": "^0.0.323",
|
"@types/chrome": "^0.0.323",
|
||||||
"@types/debug": "^4.1.12",
|
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/webextension-polyfill": "^0.12.3",
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"debug": "^4.4.1",
|
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.1",
|
"vite": "^6.0.1",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10"
|
||||||
|
@ -1247,16 +1245,6 @@
|
||||||
"@types/har-format": "*"
|
"@types/har-format": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/debug": {
|
|
||||||
"version": "4.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
|
||||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/ms": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
|
||||||
|
@ -1287,13 +1275,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ms": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.15.21",
|
"version": "22.15.21",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
|
||||||
|
@ -1632,24 +1613,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
|
||||||
"version": "4.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
|
||||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
@ -2332,13 +2295,6 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
|
|
|
@ -33,13 +33,11 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@types/chrome": "^0.0.323",
|
"@types/chrome": "^0.0.323",
|
||||||
"@types/debug": "^4.1.12",
|
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/webextension-polyfill": "^0.12.3",
|
"@types/webextension-polyfill": "^0.12.3",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"debug": "^4.4.1",
|
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.1",
|
"vite": "^6.0.1",
|
||||||
"vue-tsc": "^2.1.10"
|
"vue-tsc": "^2.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
console.log('aaaa')
|
console.log("aaaa")
|
||||||
|
|
||||||
// 兼容 Chrome 和 Firefox
|
// 兼容 Chrome 和 Firefox
|
||||||
const browserAPI = typeof browser !== 'undefined' ? browser : chrome
|
const browserAPI = typeof browser !== 'undefined' ? browser : chrome;
|
||||||
|
|
||||||
browserAPI.webRequest.onBeforeRequest.addListener(
|
browserAPI.webRequest.onBeforeRequest.addListener(
|
||||||
async (details) => {
|
async (details) => {
|
||||||
|
@ -16,18 +16,12 @@ browserAPI.webRequest.onBeforeRequest.addListener(
|
||||||
console.log('recived request for fontset api, redirecting to index.html')
|
console.log('recived request for fontset api, redirecting to index.html')
|
||||||
const pref = await browserAPI.storage.sync.get('preferences')
|
const pref = await browserAPI.storage.sync.get('preferences')
|
||||||
|
|
||||||
if (
|
if (pref === undefined || pref.preferences === undefined || pref.preferences.autoRedirect === undefined || pref.preferences.autoRedirect === true) {
|
||||||
pref === undefined ||
|
const isChrome = typeof browserAPI.runtime.getBrowserInfo === 'undefined';
|
||||||
pref.preferences === undefined ||
|
|
||||||
pref.preferences.autoRedirect === undefined ||
|
|
||||||
pref.preferences.autoRedirect === true
|
|
||||||
) {
|
|
||||||
const isChrome = typeof browserAPI.runtime.getBrowserInfo === 'undefined'
|
|
||||||
|
|
||||||
if (isChrome) {
|
if (isChrome) {
|
||||||
if (
|
if (
|
||||||
details.url ===
|
details.url === 'https://monster-siren.hypergryph.com/manifest.json' &&
|
||||||
'https://monster-siren.hypergryph.com/manifest.json' &&
|
|
||||||
details.type === 'other' &&
|
details.type === 'other' &&
|
||||||
details.frameId === 0
|
details.frameId === 0
|
||||||
) {
|
) {
|
||||||
|
@ -38,24 +32,17 @@ browserAPI.webRequest.onBeforeRequest.addListener(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Firefox: 直接在当前标签页导航
|
// Firefox: 直接在当前标签页导航
|
||||||
browserAPI.tabs.update(details.tabId, {
|
browserAPI.tabs.update(details.tabId, { url: browserAPI.runtime.getURL('index.html') })
|
||||||
url: browserAPI.runtime.getURL('index.html'),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{ urls: ['https://monster-siren.hypergryph.com/api/fontset', 'https://monster-siren.hypergryph.com/manifest.json'] },
|
||||||
urls: [
|
|
||||||
'https://monster-siren.hypergryph.com/api/fontset',
|
|
||||||
'https://monster-siren.hypergryph.com/manifest.json',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 兼容新旧版本的 API
|
// 兼容新旧版本的 API
|
||||||
const actionAPI = browserAPI.action || browserAPI.browserAction
|
const actionAPI = browserAPI.action || browserAPI.browserAction;
|
||||||
if (actionAPI) {
|
if (actionAPI) {
|
||||||
actionAPI.onClicked.addListener(() => {
|
actionAPI.onClicked.addListener(() => {
|
||||||
browserAPI.tabs.create({ url: browserAPI.runtime.getURL('index.html') })
|
browserAPI.tabs.create({ url: browserAPI.runtime.getURL('index.html') })
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -5,8 +5,12 @@
|
||||||
"description": "塞壬唱片(Monster Siren Records)官网的替代前端。",
|
"description": "塞壬唱片(Monster Siren Records)官网的替代前端。",
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["https://monster-siren.hypergryph.com/"],
|
"matches": [
|
||||||
"js": ["content.js"],
|
"https://monster-siren.hypergryph.com/"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
"run_at": "document_end"
|
"run_at": "document_end"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -32,9 +36,13 @@
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
},
|
},
|
||||||
"permissions": ["tabs", "webRequest", "storage"],
|
"permissions": [
|
||||||
|
"tabs",
|
||||||
|
"webRequest",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "default-src 'self'; script-src 'self' http://localhost:5173; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:5173 https://monster-siren.hypergryph.com https://web.hycdn.cn https://res01.hycdn.cn; img-src 'self' https://web.hycdn.cn; media-src 'self' https://res01.hycdn.cn;",
|
"extension_pages": "default-src 'self'; script-src 'self' http://localhost:5173; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:5173 https://monster-siren.hypergryph.com https://web.hycdn.cn https://res01.hycdn.cn; img-src 'self' https://web.hycdn.cn; media-src 'self' https://res01.hycdn.cn;",
|
||||||
"sandbox": "sandbox"
|
"sandbox": "sandbox"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,65 +1,61 @@
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// 处理 manifest.json
|
// 处理 manifest.json
|
||||||
function processManifest() {
|
function processManifest() {
|
||||||
const manifestPath = path.join(__dirname, '../public/manifest.json')
|
const manifestPath = path.join(__dirname, '../public/manifest.json');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
|
||||||
// 移除本地调试相关的配置
|
// 移除本地调试相关的配置
|
||||||
if (manifest.host_permissions) {
|
if (manifest.host_permissions) {
|
||||||
manifest.host_permissions = manifest.host_permissions.filter(
|
manifest.host_permissions = manifest.host_permissions.filter(
|
||||||
(permission) => !permission.includes('localhost'),
|
permission => !permission.includes('localhost')
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
|
||||||
manifest.content_security_policy &&
|
// 移除 CSP 中的本地开发相关配置
|
||||||
manifest.content_security_policy.extension_pages
|
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
|
||||||
) {
|
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
||||||
// 移除 CSP 中的本地开发相关配置
|
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages =
|
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages
|
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
||||||
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
||||||
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
.trim();
|
||||||
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
}
|
||||||
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
|
||||||
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||||
.trim()
|
console.log('✅ Manifest.json processed');
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
||||||
console.log('✅ Manifest.json processed')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 index.html
|
// 处理 index.html
|
||||||
function processIndexHtml() {
|
function processIndexHtml() {
|
||||||
const indexPath = path.join(__dirname, '../index.html')
|
const indexPath = path.join(__dirname, '../index.html');
|
||||||
let content = fs.readFileSync(indexPath, 'utf8')
|
let content = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
// 替换脚本地址
|
// 替换脚本地址
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/src="[^"]*\/src\/main\.ts"/g,
|
/src="[^"]*\/src\/main\.ts"/g,
|
||||||
'src="./src/main.ts"',
|
'src="./src/main.ts"'
|
||||||
)
|
);
|
||||||
|
|
||||||
// 移除 crossorigin 属性
|
// 移除 crossorigin 属性
|
||||||
content = content.replace(/\s+crossorigin/g, '')
|
content = content.replace(/\s+crossorigin/g, '');
|
||||||
|
|
||||||
fs.writeFileSync(indexPath, content)
|
fs.writeFileSync(indexPath, content);
|
||||||
console.log('✅ Index.html processed')
|
console.log('✅ Index.html processed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行处理
|
// 执行处理
|
||||||
try {
|
try {
|
||||||
processManifest()
|
processManifest();
|
||||||
processIndexHtml()
|
processIndexHtml();
|
||||||
console.log('🎉 Build preparation completed!')
|
console.log('🎉 Build preparation completed!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error during build preparation:', error)
|
console.error('❌ Error during build preparation:', error);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
|
@ -1,87 +1,80 @@
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// 处理 manifest.json
|
// 处理 manifest.json
|
||||||
function processManifest() {
|
function processManifest() {
|
||||||
const manifestPath = path.join(__dirname, '../public/manifest.json')
|
const manifestPath = path.join(__dirname, '../public/manifest.json');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
|
||||||
// 移除本地调试相关的配置
|
// 移除本地调试相关的配置
|
||||||
if (manifest.host_permissions) {
|
if (manifest.host_permissions) {
|
||||||
manifest.host_permissions = manifest.host_permissions.filter(
|
manifest.host_permissions = manifest.host_permissions.filter(
|
||||||
(permission) => !permission.includes('localhost'),
|
permission => !permission.includes('localhost')
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
|
||||||
manifest.content_security_policy &&
|
// 移除 CSP 中的本地开发相关配置
|
||||||
manifest.content_security_policy.extension_pages
|
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
|
||||||
) {
|
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
||||||
// 移除 CSP 中的本地开发相关配置
|
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages =
|
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages
|
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
||||||
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
||||||
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
.trim();
|
||||||
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
}
|
||||||
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
|
||||||
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除 CSP 中的 sandbox 配置(Firefox 不支持)
|
// 移除 CSP 中的 sandbox 配置(Firefox 不支持)
|
||||||
if (
|
if (manifest.content_security_policy && manifest.content_security_policy.sandbox) {
|
||||||
manifest.content_security_policy &&
|
delete manifest.content_security_policy.sandbox;
|
||||||
manifest.content_security_policy.sandbox
|
}
|
||||||
) {
|
|
||||||
delete manifest.content_security_policy.sandbox
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除 background.service_worker,替换为 background.scripts
|
// 移除 background.service_worker,替换为 background.scripts
|
||||||
if (manifest.background && manifest.background.service_worker) {
|
if (manifest.background && manifest.background.service_worker) {
|
||||||
manifest.background.scripts = [manifest.background.service_worker]
|
manifest.background.scripts = [manifest.background.service_worker];
|
||||||
delete manifest.background.service_worker
|
delete manifest.background.service_worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加 firefox 特有配置
|
// 添加 firefox 特有配置
|
||||||
manifest.browser_specific_settings = {
|
manifest.browser_specific_settings = {
|
||||||
gecko: {
|
gecko: {
|
||||||
id: 'msr-mod@firefox-addon.astrian.moe',
|
id: 'msr-mod@firefox-addon.astrian.moe',
|
||||||
strict_min_version: '115.0',
|
strict_min_version: '115.0',
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||||
console.log('✅ Manifest.json processed')
|
console.log('✅ Manifest.json processed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 index.html
|
// 处理 index.html
|
||||||
function processIndexHtml() {
|
function processIndexHtml() {
|
||||||
const indexPath = path.join(__dirname, '../index.html')
|
const indexPath = path.join(__dirname, '../index.html');
|
||||||
let content = fs.readFileSync(indexPath, 'utf8')
|
let content = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
// 替换脚本地址
|
// 替换脚本地址
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/src="[^"]*\/src\/main\.ts"/g,
|
/src="[^"]*\/src\/main\.ts"/g,
|
||||||
'src="./src/main.ts"',
|
'src="./src/main.ts"'
|
||||||
)
|
);
|
||||||
|
|
||||||
// 移除 crossorigin 属性
|
// 移除 crossorigin 属性
|
||||||
content = content.replace(/\s+crossorigin/g, '')
|
content = content.replace(/\s+crossorigin/g, '');
|
||||||
|
|
||||||
fs.writeFileSync(indexPath, content)
|
fs.writeFileSync(indexPath, content);
|
||||||
console.log('✅ Index.html processed')
|
console.log('✅ Index.html processed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行处理
|
// 执行处理
|
||||||
try {
|
try {
|
||||||
processManifest()
|
processManifest();
|
||||||
processIndexHtml()
|
processIndexHtml();
|
||||||
console.log('🎉 Build preparation completed!')
|
console.log('🎉 Build preparation completed!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error during build preparation:', error)
|
console.error('❌ Error during build preparation:', error);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
|
@ -1,59 +1,55 @@
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
import path from 'path'
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// 处理 manifest.json for Safari
|
// 处理 manifest.json for Safari
|
||||||
function processManifest() {
|
function processManifest() {
|
||||||
const manifestPath = path.join(__dirname, '../public/manifest.json')
|
const manifestPath = path.join(__dirname, '../public/manifest.json');
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
|
||||||
// 移除本地调试相关的配置
|
// 移除本地调试相关的配置
|
||||||
if (manifest.host_permissions) {
|
if (manifest.host_permissions) {
|
||||||
manifest.host_permissions = manifest.host_permissions.filter(
|
manifest.host_permissions = manifest.host_permissions.filter(
|
||||||
(permission) => !permission.includes('localhost'),
|
permission => !permission.includes('localhost')
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
|
||||||
manifest.content_security_policy &&
|
// 移除 CSP 中的本地开发相关配置
|
||||||
manifest.content_security_policy.extension_pages
|
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
|
||||||
) {
|
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
||||||
// 移除 CSP 中的本地开发相关配置
|
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages =
|
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
||||||
manifest.content_security_policy.extension_pages
|
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
||||||
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
|
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
||||||
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
|
.trim();
|
||||||
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
|
}
|
||||||
.replace(/;\s+/g, '; ') // 标准化分号后的空格
|
|
||||||
.replace(/\s+/g, ' ') // 合并多个空格为一个
|
|
||||||
.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Safari 特殊处理:添加 appShell.html 到 content scripts 匹配
|
// Safari 特殊处理:添加 appShell.html 到 content scripts 匹配
|
||||||
if (manifest.content_scripts && manifest.content_scripts[0]) {
|
if (manifest.content_scripts && manifest.content_scripts[0]) {
|
||||||
// 添加 appShell.html 的匹配规则
|
// 添加 appShell.html 的匹配规则
|
||||||
const existingMatches = manifest.content_scripts[0].matches
|
const existingMatches = manifest.content_scripts[0].matches;
|
||||||
if (!existingMatches.includes('https://monster-siren.hypergryph.com/')) {
|
if (!existingMatches.includes("https://monster-siren.hypergryph.com/")) {
|
||||||
existingMatches.push('https://monster-siren.hypergryph.com/')
|
existingMatches.push("https://monster-siren.hypergryph.com/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safari 特殊处理:使用 background.page 而不是 service_worker
|
// Safari 特殊处理:使用 background.page 而不是 service_worker
|
||||||
if (manifest.background && manifest.background.service_worker) {
|
if (manifest.background && manifest.background.service_worker) {
|
||||||
// Safari 扩展在 Manifest V3 中必须使用 persistent: false
|
// Safari 扩展在 Manifest V3 中必须使用 persistent: false
|
||||||
// 但为了调试,我们暂时设为 true 来确保页面加载
|
// 但为了调试,我们暂时设为 true 来确保页面加载
|
||||||
manifest.background = {
|
manifest.background = {
|
||||||
page: 'background.html',
|
page: "background.html",
|
||||||
persistent: true,
|
persistent: true
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 background.html 文件用于 Safari
|
// 创建 background.html 文件用于 Safari
|
||||||
const backgroundHtmlPath = path.join(__dirname, '../public/background.html')
|
const backgroundHtmlPath = path.join(__dirname, '../public/background.html');
|
||||||
const backgroundHtmlContent = `<!DOCTYPE html>
|
const backgroundHtmlContent = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
@ -106,19 +102,19 @@ function processManifest() {
|
||||||
log('=== After background.js script tag ===');
|
log('=== After background.js script tag ===');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`;
|
||||||
fs.writeFileSync(backgroundHtmlPath, backgroundHtmlContent)
|
fs.writeFileSync(backgroundHtmlPath, backgroundHtmlContent);
|
||||||
|
|
||||||
// 创建 Safari 兼容的 background.js
|
// 创建 Safari 兼容的 background.js
|
||||||
const backgroundJsPath = path.join(__dirname, '../public/background.js')
|
const backgroundJsPath = path.join(__dirname, '../public/background.js');
|
||||||
let backgroundJsContent = fs.readFileSync(backgroundJsPath, 'utf8')
|
let backgroundJsContent = fs.readFileSync(backgroundJsPath, 'utf8');
|
||||||
|
|
||||||
// 检查是否已经添加过 Safari 代码,避免重复
|
// 检查是否已经添加过 Safari 代码,避免重复
|
||||||
if (backgroundJsContent.includes('=== Safari background.js starting ===')) {
|
if (backgroundJsContent.includes('=== Safari background.js starting ===')) {
|
||||||
console.log('Safari background.js already processed, skipping...')
|
console.log('Safari background.js already processed, skipping...');
|
||||||
} else {
|
} else {
|
||||||
// 在开头添加 Safari 调试信息(只添加一次)
|
// 在开头添加 Safari 调试信息(只添加一次)
|
||||||
const safariDebugCode = `
|
const safariDebugCode = `
|
||||||
console.log("=== Safari background.js starting ===");
|
console.log("=== Safari background.js starting ===");
|
||||||
console.log("Available APIs:", {
|
console.log("Available APIs:", {
|
||||||
chrome: typeof chrome,
|
chrome: typeof chrome,
|
||||||
|
@ -172,42 +168,40 @@ if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
`
|
`;
|
||||||
|
|
||||||
|
// 替换 Safari 的重定向 URL 监听
|
||||||
|
backgroundJsContent = backgroundJsContent.replace(
|
||||||
|
/{ urls: \['https:\/\/monster-siren\.hypergryph\.com\/api\/fontset', 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'\] }/g,
|
||||||
|
"{ urls: ['https://monster-siren.hypergryph.com/api/fontset', 'https://monster-siren.hypergryph.com/manifest.json', 'https://monster-siren.hypergryph.com/'] }"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 替换 Safari 的重定向判断逻辑
|
||||||
|
backgroundJsContent = backgroundJsContent.replace(
|
||||||
|
/details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'/g,
|
||||||
|
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清理可能的重复条件
|
||||||
|
backgroundJsContent = backgroundJsContent.replace(
|
||||||
|
/\(\(details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json' \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\) \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\)/g,
|
||||||
|
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')"
|
||||||
|
);
|
||||||
|
|
||||||
|
backgroundJsContent = safariDebugCode + backgroundJsContent;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(backgroundJsPath, backgroundJsContent);
|
||||||
|
console.log('✅ Safari-compatible background.js created');
|
||||||
|
|
||||||
// 替换 Safari 的重定向 URL 监听
|
// 创建 Safari 专用的 content.js
|
||||||
backgroundJsContent = backgroundJsContent.replace(
|
const contentJsPath = path.join(__dirname, '../public/content.js');
|
||||||
/{ urls: \['https:\/\/monster-siren\.hypergryph\.com\/api\/fontset', 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'\] }/g,
|
|
||||||
"{ urls: ['https://monster-siren.hypergryph.com/api/fontset', 'https://monster-siren.hypergryph.com/manifest.json', 'https://monster-siren.hypergryph.com/'] }",
|
// 检查是否已经处理过 content.js
|
||||||
)
|
const existingContentJs = fs.existsSync(contentJsPath) ? fs.readFileSync(contentJsPath, 'utf8') : '';
|
||||||
|
if (existingContentJs.includes('checkRedirectPreference')) {
|
||||||
// 替换 Safari 的重定向判断逻辑
|
console.log('Safari content.js already processed, skipping...');
|
||||||
backgroundJsContent = backgroundJsContent.replace(
|
} else {
|
||||||
/details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json'/g,
|
const contentJsContent = `
|
||||||
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')",
|
|
||||||
)
|
|
||||||
|
|
||||||
// 清理可能的重复条件
|
|
||||||
backgroundJsContent = backgroundJsContent.replace(
|
|
||||||
/\(\(details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/manifest\.json' \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\) \|\| details\.url === 'https:\/\/monster-siren\.hypergryph\.com\/'\)/g,
|
|
||||||
"(details.url === 'https://monster-siren.hypergryph.com/manifest.json' || details.url === 'https://monster-siren.hypergryph.com/')",
|
|
||||||
)
|
|
||||||
|
|
||||||
backgroundJsContent = safariDebugCode + backgroundJsContent
|
|
||||||
}
|
|
||||||
fs.writeFileSync(backgroundJsPath, backgroundJsContent)
|
|
||||||
console.log('✅ Safari-compatible background.js created')
|
|
||||||
|
|
||||||
// 创建 Safari 专用的 content.js
|
|
||||||
const contentJsPath = path.join(__dirname, '../public/content.js')
|
|
||||||
|
|
||||||
// 检查是否已经处理过 content.js
|
|
||||||
const existingContentJs = fs.existsSync(contentJsPath)
|
|
||||||
? fs.readFileSync(contentJsPath, 'utf8')
|
|
||||||
: ''
|
|
||||||
if (existingContentJs.includes('checkRedirectPreference')) {
|
|
||||||
console.log('Safari content.js already processed, skipping...')
|
|
||||||
} else {
|
|
||||||
const contentJsContent = `
|
|
||||||
// Safari 扩展 content script for redirect
|
// Safari 扩展 content script for redirect
|
||||||
console.log('MSR Mod content script loaded on:', window.location.href);
|
console.log('MSR Mod content script loaded on:', window.location.href);
|
||||||
|
|
||||||
|
@ -313,53 +307,53 @@ async function main() {
|
||||||
main().catch(error => {
|
main().catch(error => {
|
||||||
console.error('Error in main function:', error);
|
console.error('Error in main function:', error);
|
||||||
});
|
});
|
||||||
`
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(contentJsPath, contentJsContent);
|
||||||
|
}
|
||||||
|
console.log('✅ Safari-compatible content.js created');
|
||||||
|
|
||||||
fs.writeFileSync(contentJsPath, contentJsContent)
|
// Safari 可能需要额外的权限
|
||||||
}
|
if (!manifest.permissions.includes('activeTab')) {
|
||||||
console.log('✅ Safari-compatible content.js created')
|
manifest.permissions.push('activeTab');
|
||||||
|
}
|
||||||
|
|
||||||
// Safari 可能需要额外的权限
|
// 添加 Safari 特有配置
|
||||||
if (!manifest.permissions.includes('activeTab')) {
|
manifest.browser_specific_settings = {
|
||||||
manifest.permissions.push('activeTab')
|
safari: {
|
||||||
}
|
minimum_version: "14.0"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 添加 Safari 特有配置
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||||
manifest.browser_specific_settings = {
|
console.log('✅ Safari Manifest.json processed');
|
||||||
safari: {
|
console.log('✅ Background.html created for Safari');
|
||||||
minimum_version: '14.0',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
||||||
console.log('✅ Safari Manifest.json processed')
|
|
||||||
console.log('✅ Background.html created for Safari')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 index.html
|
// 处理 index.html
|
||||||
function processIndexHtml() {
|
function processIndexHtml() {
|
||||||
const indexPath = path.join(__dirname, '../index.html')
|
const indexPath = path.join(__dirname, '../index.html');
|
||||||
let content = fs.readFileSync(indexPath, 'utf8')
|
let content = fs.readFileSync(indexPath, 'utf8');
|
||||||
|
|
||||||
// 替换脚本地址
|
// 替换脚本地址
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/src="[^"]*\/src\/main\.ts"/g,
|
/src="[^"]*\/src\/main\.ts"/g,
|
||||||
'src="./src/main.ts"',
|
'src="./src/main.ts"'
|
||||||
)
|
);
|
||||||
|
|
||||||
// 移除 crossorigin 属性
|
// 移除 crossorigin 属性
|
||||||
content = content.replace(/\s+crossorigin/g, '')
|
content = content.replace(/\s+crossorigin/g, '');
|
||||||
|
|
||||||
fs.writeFileSync(indexPath, content)
|
fs.writeFileSync(indexPath, content);
|
||||||
console.log('✅ Index.html processed for Safari')
|
console.log('✅ Index.html processed for Safari');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行处理
|
// 执行处理
|
||||||
try {
|
try {
|
||||||
processManifest()
|
processManifest();
|
||||||
processIndexHtml()
|
processIndexHtml();
|
||||||
console.log('🎉 Safari build preparation completed!')
|
console.log('🎉 Safari build preparation completed!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error during Safari build preparation:', error)
|
console.error('❌ Error during Safari build preparation:', error);
|
||||||
process.exit(1)
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
20
src/App.vue
20
src/App.vue
|
@ -1,15 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import MiniPlayer from './components/MiniPlayer.vue'
|
import Player from './components/Player.vue'
|
||||||
import PreferencePanel from './components/PreferencePanel.vue'
|
import PreferencePanel from './components/PreferencePanel.vue'
|
||||||
import PlayerWebAudio from './components/PlayerWebAudio.vue'
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import LeftArrowIcon from './assets/icons/leftarrow.vue'
|
import LeftArrowIcon from './assets/icons/leftarrow.vue'
|
||||||
// import SearchIcon from './assets/icons/search.vue'
|
// import SearchIcon from './assets/icons/search.vue'
|
||||||
import CorgIcon from './assets/icons/corg.vue'
|
import CorgIcon from './assets/icons/corg.vue'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
import { debug } from './utils/debug'
|
|
||||||
|
|
||||||
import UpdatePopup from './components/UpdatePopup.vue'
|
import UpdatePopup from './components/UpdatePopup.vue'
|
||||||
|
|
||||||
|
@ -18,12 +16,10 @@ const presentPreferencePanel = ref(false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
watch(
|
watch(() => presentPreferencePanel, (value) => {
|
||||||
() => presentPreferencePanel,
|
console.log(value)
|
||||||
(value) => {
|
})
|
||||||
debug('偏好设置面板显示状态', value)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -71,17 +67,13 @@ watch(
|
||||||
<SearchIcon :size="4" />
|
<SearchIcon :size="4" />
|
||||||
</button> -->
|
</button> -->
|
||||||
|
|
||||||
<PlayerWebAudio />
|
|
||||||
|
|
||||||
|
|
||||||
<MiniPlayer />
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
class="text-white w-9 h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center"
|
||||||
@click="presentPreferencePanel = true">
|
@click="presentPreferencePanel = true">
|
||||||
<CorgIcon :size="4" />
|
<CorgIcon :size="4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<Player />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -9,41 +9,33 @@ export default {
|
||||||
const songs: {
|
const songs: {
|
||||||
data: ApiResponse
|
data: ApiResponse
|
||||||
} = await msrInstance.get('songs')
|
} = await msrInstance.get('songs')
|
||||||
if (songs.data.code !== 0) {
|
if (songs.data.code !== 0) { throw new Error(`Cannot get songs: ${songs.data.msg}`) }
|
||||||
throw new Error(`Cannot get songs: ${songs.data.msg}`)
|
|
||||||
}
|
|
||||||
return { songs: songs.data.data as { list: SongList } }
|
return { songs: songs.data.data as { list: SongList } }
|
||||||
},
|
},
|
||||||
async getSong(cid: string) {
|
async getSong(cid: string) {
|
||||||
const song: {
|
const song: {
|
||||||
data: ApiResponse
|
data: ApiResponse
|
||||||
} = await msrInstance.get(`song/${cid}`)
|
} = await msrInstance.get(`song/${cid}`)
|
||||||
if (song.data.code !== 0) {
|
if (song.data.code!== 0) { throw new Error(`Cannot get song: ${song.data.msg}`) }
|
||||||
throw new Error(`Cannot get song: ${song.data.msg}`)
|
|
||||||
}
|
|
||||||
return song.data.data as Song
|
return song.data.data as Song
|
||||||
},
|
},
|
||||||
async getAlbums() {
|
async getAlbums() {
|
||||||
const albums: {
|
const albums: {
|
||||||
data: ApiResponse
|
data: ApiResponse
|
||||||
} = await msrInstance.get('albums')
|
} = await msrInstance.get('albums')
|
||||||
if (albums.data.code !== 0) {
|
if (albums.data.code!== 0) { throw new Error(`Cannot get albums: ${albums.data.msg}`) }
|
||||||
throw new Error(`Cannot get albums: ${albums.data.msg}`)
|
|
||||||
}
|
|
||||||
return albums.data.data as AlbumList
|
return albums.data.data as AlbumList
|
||||||
},
|
},
|
||||||
async getAlbum(cid: string) {
|
async getAlbum(cid: string) {
|
||||||
const album: {
|
const album: {
|
||||||
data: ApiResponse
|
data: ApiResponse
|
||||||
} = await msrInstance.get(`album/${cid}/detail`)
|
} = await msrInstance.get(`album/${cid}/detail`)
|
||||||
if (album.data.code !== 0) {
|
if (album.data.code!== 0) { throw new Error(`Cannot get album: ${album.data.msg}`) }
|
||||||
throw new Error(`Cannot get album: ${album.data.msg}`)
|
|
||||||
}
|
|
||||||
const albumMeta: {
|
const albumMeta: {
|
||||||
data: ApiResponse
|
data: ApiResponse
|
||||||
} = await msrInstance.get(`album/${cid}/data`)
|
} = await msrInstance.get(`album/${cid}/data`)
|
||||||
let data = album.data.data as Album
|
let data = album.data.data as Album
|
||||||
data.artistes = (albumMeta.data.data as Album).artistes
|
data.artistes = (albumMeta.data.data as Album).artistes
|
||||||
return data
|
return data
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,8 @@ import { gsap } from 'gsap'
|
||||||
import apis from '../apis'
|
import apis from '../apis'
|
||||||
import { artistsOrganize } from '../utils'
|
import { artistsOrganize } from '../utils'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { usePlayState } from '../stores/usePlayState'
|
|
||||||
import TrackItem from './TrackItem.vue'
|
import TrackItem from './TrackItem.vue'
|
||||||
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
|
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
|
||||||
import { debugUI } from '../utils/debug'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
albumCid: string
|
albumCid: string
|
||||||
|
@ -30,8 +28,7 @@ const closeButton = ref<HTMLElement>()
|
||||||
|
|
||||||
// Animation functions
|
// Animation functions
|
||||||
const animateIn = async () => {
|
const animateIn = async () => {
|
||||||
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value)
|
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) return
|
||||||
return
|
|
||||||
|
|
||||||
// Set initial states
|
// Set initial states
|
||||||
gsap.set(dialogBackdrop.value, { opacity: 0 })
|
gsap.set(dialogBackdrop.value, { opacity: 0 })
|
||||||
|
@ -44,126 +41,108 @@ const animateIn = async () => {
|
||||||
tl.to(dialogBackdrop.value, {
|
tl.to(dialogBackdrop.value, {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
ease: 'power2.out',
|
ease: "power2.out"
|
||||||
})
|
})
|
||||||
.to(
|
.to(dialogContent.value, {
|
||||||
dialogContent.value,
|
y: 0,
|
||||||
{
|
opacity: 1,
|
||||||
y: 0,
|
scale: 1,
|
||||||
opacity: 1,
|
duration: 0.4,
|
||||||
scale: 1,
|
ease: "power3.out"
|
||||||
duration: 0.4,
|
}, "-=0.1")
|
||||||
ease: 'power3.out',
|
.to(closeButton.value, {
|
||||||
},
|
scale: 1,
|
||||||
'-=0.1',
|
rotation: 0,
|
||||||
)
|
duration: 0.3,
|
||||||
.to(
|
ease: "back.out(1.7)"
|
||||||
closeButton.value,
|
}, "-=0.2")
|
||||||
{
|
|
||||||
scale: 1,
|
|
||||||
rotation: 0,
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'back.out(1.7)',
|
|
||||||
},
|
|
||||||
'-=0.2',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const animateOut = () => {
|
const animateOut = () => {
|
||||||
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value)
|
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) return
|
||||||
return
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => emit('dismiss'),
|
onComplete: () => emit('dismiss')
|
||||||
})
|
})
|
||||||
|
|
||||||
tl.to(closeButton.value, {
|
tl.to(closeButton.value, {
|
||||||
scale: 0,
|
scale: 0,
|
||||||
rotation: 180,
|
rotation: 180,
|
||||||
duration: 0.2,
|
duration: 0.2,
|
||||||
ease: 'power2.in',
|
ease: "power2.in"
|
||||||
})
|
})
|
||||||
.to(
|
.to(dialogContent.value, {
|
||||||
dialogContent.value,
|
y: 30,
|
||||||
{
|
opacity: 0,
|
||||||
y: 30,
|
scale: 0.95,
|
||||||
opacity: 0,
|
duration: 0.3,
|
||||||
scale: 0.95,
|
ease: "power2.in"
|
||||||
duration: 0.3,
|
}, "-=0.1")
|
||||||
ease: 'power2.in',
|
.to(dialogBackdrop.value, {
|
||||||
},
|
opacity: 0,
|
||||||
'-=0.1',
|
duration: 0.2,
|
||||||
)
|
ease: "power2.in"
|
||||||
.to(
|
}, "-=0.1")
|
||||||
dialogBackdrop.value,
|
|
||||||
{
|
|
||||||
opacity: 0,
|
|
||||||
duration: 0.2,
|
|
||||||
ease: 'power2.in',
|
|
||||||
},
|
|
||||||
'-=0.1',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
animateOut()
|
animateOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => props.present, async (newVal) => {
|
||||||
() => props.present,
|
if (newVal) {
|
||||||
async (newVal) => {
|
await nextTick()
|
||||||
if (newVal) {
|
animateIn()
|
||||||
await nextTick()
|
}
|
||||||
animateIn()
|
})
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(() => props.albumCid, async () => {
|
||||||
() => props.albumCid,
|
console.log("AlbumDetailDialog mounted with albumCid:", props.albumCid)
|
||||||
async () => {
|
album.value = undefined // Reset album when cid changes
|
||||||
debugUI('专辑详情对话框加载', props.albumCid)
|
try {
|
||||||
album.value = undefined // Reset album when cid changes
|
let res = await apis.getAlbum(props.albumCid)
|
||||||
try {
|
for (const track in res.songs) {
|
||||||
let res = await apis.getAlbum(props.albumCid)
|
res.songs[parseInt(track)] = await apis.getSong(res.songs[parseInt(track)].cid)
|
||||||
debugUI(res.cid)
|
|
||||||
for (const track in res.songs) {
|
|
||||||
res.songs[parseInt(track)] = await apis.getSong(
|
|
||||||
res.songs[parseInt(track)].cid,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
album.value = res
|
|
||||||
} catch (error) {
|
|
||||||
debugUI('专辑详情加载失败', error)
|
|
||||||
}
|
}
|
||||||
},
|
album.value = res
|
||||||
)
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
const playQueue = usePlayQueueStore()
|
||||||
const playState = usePlayState()
|
|
||||||
|
|
||||||
async function playTheAlbum(from: number = 0) {
|
function playTheAlbum(from: number = 0) {
|
||||||
let newQueue = []
|
if (playQueue.queueReplaceLock) {
|
||||||
|
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
|
||||||
|
playQueue.queueReplaceLock = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPlayQueue = []
|
||||||
for (const track of album.value?.songs ?? []) {
|
for (const track of album.value?.songs ?? []) {
|
||||||
newQueue.push({
|
console.log(track)
|
||||||
|
newPlayQueue.push({
|
||||||
song: track,
|
song: track,
|
||||||
album: album.value,
|
album: album.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
playQueue.replaceQueue(newQueue)
|
playQueue.list = newPlayQueue
|
||||||
playState.togglePlay(true)
|
playQueue.currentIndex = from
|
||||||
|
playQueue.isPlaying = true
|
||||||
|
playQueue.isBuffering = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function shuffle() {
|
function shuffle() {
|
||||||
// playTheAlbum()
|
playTheAlbum()
|
||||||
// playQueue.shuffleCurrent = true
|
playQueue.shuffleCurrent = true
|
||||||
// playQueue.playMode.shuffle = false
|
playQueue.playMode.shuffle = false
|
||||||
// setTimeout(() => {
|
setTimeout(() => {
|
||||||
// playQueue.playMode.shuffle = true
|
playQueue.playMode.shuffle = true
|
||||||
// playQueue.isPlaying = true
|
playQueue.isPlaying = true
|
||||||
// playQueue.isBuffering = true
|
playQueue.isBuffering = true
|
||||||
// }, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -256,4 +235,4 @@ function shuffle() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
|
@ -1,21 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const playQueue = usePlayQueueStore()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterLink to="/playroom" v-if="playQueue.currentTrack">
|
|
||||||
<div
|
|
||||||
class="h-9 w-52 bg-neutral-800/80 border border-[#ffffff39] rounded-full backdrop-blur-3xl flex items-center justify-between select-none overflow-hidden">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="rounded-full w-9 h-9 bg-gray-600 overflow-hidden">
|
|
||||||
<img :src="playQueue.currentTrack.album?.coverUrl ?? ''" />
|
|
||||||
</div>
|
|
||||||
<div class="text-white">{{playQueue.currentTrack.song.name}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
|
@ -59,9 +59,7 @@ function moveUp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveDown() {
|
function moveDown() {
|
||||||
const listLength = playQueueStore.playMode.shuffle
|
const listLength = playQueueStore.playMode.shuffle ? playQueueStore.shuffleList.length : playQueueStore.list.length
|
||||||
? playQueueStore.shuffleList.length
|
|
||||||
: playQueueStore.list.length
|
|
||||||
if (props.index === listLength - 1) return
|
if (props.index === listLength - 1) return
|
||||||
|
|
||||||
playQueueStore.queueReplaceLock = true
|
playQueueStore.queueReplaceLock = true
|
||||||
|
@ -111,10 +109,7 @@ function removeItem() {
|
||||||
playQueueStore.currentIndex--
|
playQueueStore.currentIndex--
|
||||||
} else if (props.index === playQueueStore.currentIndex) {
|
} else if (props.index === playQueueStore.currentIndex) {
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
playQueueStore.currentIndex = Math.min(
|
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1)
|
||||||
playQueueStore.currentIndex,
|
|
||||||
queue.length - 1,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
playQueueStore.currentIndex = 0
|
playQueueStore.currentIndex = 0
|
||||||
}
|
}
|
||||||
|
@ -145,10 +140,7 @@ function removeItem() {
|
||||||
playQueueStore.currentIndex--
|
playQueueStore.currentIndex--
|
||||||
} else if (props.index === playQueueStore.currentIndex) {
|
} else if (props.index === playQueueStore.currentIndex) {
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
playQueueStore.currentIndex = Math.min(
|
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1)
|
||||||
playQueueStore.currentIndex,
|
|
||||||
queue.length - 1,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
playQueueStore.currentIndex = 0
|
playQueueStore.currentIndex = 0
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,78 +1,163 @@
|
||||||
|
<!-- Player.vue - 添加预加载功能 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useFavourites } from '../stores/useFavourites'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { debugPlayer } from '../utils/debug'
|
|
||||||
import { watch, ref } from 'vue'
|
|
||||||
import apis from '../apis'
|
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
|
||||||
|
import PlayIcon from '../assets/icons/play.vue'
|
||||||
|
import PauseIcon from '../assets/icons/pause.vue'
|
||||||
|
import { audioVisualizer, checkAndRefreshSongResource, supportsWebAudioVisualization } from '../utils'
|
||||||
|
|
||||||
const resourcesUrl = ref<{ [key: string]: string }>({})
|
const playQueueStore = usePlayQueueStore()
|
||||||
const audioRefs = ref<{ [key: string]: HTMLAudioElement }>({}) // audio 元素的引用
|
const favourites = useFavourites()
|
||||||
|
const route = useRoute()
|
||||||
|
const player = useTemplateRef('playerRef')
|
||||||
|
|
||||||
// 监听播放列表变化
|
// [调试] 检查 store 方法类型
|
||||||
watch(
|
console.log('[Player] 检查 store 方法:', {
|
||||||
() => playQueue.queue,
|
preloadNext: typeof playQueueStore.preloadNext,
|
||||||
async () => {
|
getPreloadedAudio: typeof playQueueStore.getPreloadedAudio,
|
||||||
debugPlayer(playQueue.queue)
|
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio,
|
||||||
let newResourcesUrl: { [key: string]: string } = {}
|
})
|
||||||
for (const track of playQueue.queue) {
|
|
||||||
const res = await apis.getSong(track.song.cid)
|
// 获取当前歌曲的计算属性
|
||||||
newResourcesUrl[track.song.cid] = track.song.sourceUrl
|
const currentTrack = computed(() => {
|
||||||
}
|
if (
|
||||||
debugPlayer(newResourcesUrl)
|
playQueueStore.playMode.shuffle &&
|
||||||
resourcesUrl.value = newResourcesUrl
|
playQueueStore.shuffleList.length > 0
|
||||||
},
|
) {
|
||||||
)
|
return playQueueStore.list[
|
||||||
|
playQueueStore.shuffleList[playQueueStore.currentIndex]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return playQueueStore.list[playQueueStore.currentIndex]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取当前歌曲的音频源
|
||||||
|
const currentAudioSrc = computed(() => {
|
||||||
|
const track = currentTrack.value
|
||||||
|
return track ? track.song.sourceUrl : ''
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => playQueue.currentTrack,
|
() => playQueueStore.isPlaying,
|
||||||
async (newTrack, oldTrack) => {
|
(newValue) => {
|
||||||
if (!playQueue.currentTrack) return
|
if (newValue) {
|
||||||
|
player.value?.play()
|
||||||
// 更新元数据
|
setMetadata()
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: playQueue.currentTrack.song.name,
|
|
||||||
artist: artistsOrganize(playQueue.currentTrack.song.artists ?? []),
|
|
||||||
album: playQueue.currentTrack.album?.name,
|
|
||||||
artwork: [
|
|
||||||
{
|
|
||||||
src: playQueue.currentTrack.album?.coverUrl ?? '',
|
|
||||||
sizes: '500x500',
|
|
||||||
type: 'image/png',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => {})
|
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', playQueue.skipToNext)
|
|
||||||
|
|
||||||
// 如果目前歌曲变动时正在播放,则激活对应的 audio 组件,并将播放时间进度重置为零
|
|
||||||
if (!playQueue.isPlaying) return
|
|
||||||
debugPlayer('正在播放,变更至下一首歌')
|
|
||||||
if (oldTrack) {
|
|
||||||
const oldAudio = getAudioElement(oldTrack.song.cid)
|
|
||||||
if (oldAudio && !oldAudio.paused) {
|
|
||||||
oldAudio.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAudio = getAudioElement(newTrack.song.cid)
|
|
||||||
if (newAudio) {
|
|
||||||
try {
|
|
||||||
await newAudio.play()
|
|
||||||
debugPlayer(`开始播放: audio-${newTrack.song.cid}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`播放失败: audio-${newTrack.song.cid}`, error)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`找不到音频元素: audio-${newTrack.song.cid}`)
|
player.value?.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 优化音乐人字符串显示
|
// 监听当前索引变化,处理预加载逻辑
|
||||||
function artistsOrganize(list: string[]) {
|
watch(
|
||||||
if (list.length === 0) return '未知音乐人'
|
() => playQueueStore.currentIndex,
|
||||||
|
async () => {
|
||||||
|
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
|
||||||
|
|
||||||
|
// 检查是否可以使用预加载的音频
|
||||||
|
const track = currentTrack.value
|
||||||
|
if (track) {
|
||||||
|
const songId = track.song.cid
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 首先检查和刷新当前歌曲的资源
|
||||||
|
console.log('[Player] 检查当前歌曲资源:', track.song.name)
|
||||||
|
const updatedSong = await checkAndRefreshSongResource(
|
||||||
|
track.song,
|
||||||
|
(updated) => {
|
||||||
|
// 更新播放队列中的歌曲信息
|
||||||
|
// 在随机播放模式下,currentIndex 是 shuffleList 的索引
|
||||||
|
// 需要通过 shuffleList[currentIndex] 获取实际的 list 索引
|
||||||
|
const actualIndex =
|
||||||
|
playQueueStore.playMode.shuffle &&
|
||||||
|
playQueueStore.shuffleList.length > 0
|
||||||
|
? playQueueStore.shuffleList[playQueueStore.currentIndex]
|
||||||
|
: playQueueStore.currentIndex
|
||||||
|
if (playQueueStore.list[actualIndex]) {
|
||||||
|
playQueueStore.list[actualIndex].song = updated
|
||||||
|
}
|
||||||
|
// 如果歌曲在收藏夹中,也更新收藏夹
|
||||||
|
favourites.updateSongInFavourites(songId, updated)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 使用更新后的歌曲信息
|
||||||
|
const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
|
||||||
|
|
||||||
|
if (preloadedAudio && updatedSong.sourceUrl === track.song.sourceUrl) {
|
||||||
|
console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
|
||||||
|
|
||||||
|
// 直接使用预加载的音频数据
|
||||||
|
if (player.value) {
|
||||||
|
// 复制预加载音频的状态到主播放器
|
||||||
|
player.value.src = preloadedAudio.src
|
||||||
|
player.value.currentTime = 0
|
||||||
|
|
||||||
|
// 清理使用过的预加载音频
|
||||||
|
playQueueStore.clearPreloadedAudio(songId)
|
||||||
|
|
||||||
|
// 如果正在播放状态,立即播放
|
||||||
|
if (playQueueStore.isPlaying) {
|
||||||
|
await nextTick()
|
||||||
|
player.value.play().catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
playQueueStore.isBuffering = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[Player] 正常加载音频: ${track.song.name}`)
|
||||||
|
playQueueStore.isBuffering = true
|
||||||
|
|
||||||
|
// 如果资源地址已更新,清除旧的预加载音频
|
||||||
|
if (updatedSong.sourceUrl !== track.song.sourceUrl) {
|
||||||
|
playQueueStore.clearPreloadedAudio(songId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Player] 处理预加载音频时出错:', error)
|
||||||
|
playQueueStore.isBuffering = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMetadata()
|
||||||
|
|
||||||
|
// 延迟预加载下一首歌,避免影响当前歌曲加载
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[Player] 尝试预加载下一首歌')
|
||||||
|
|
||||||
|
// 检查函数是否存在
|
||||||
|
if (typeof playQueueStore.preloadNext === 'function') {
|
||||||
|
await playQueueStore.preloadNext()
|
||||||
|
|
||||||
|
// 预加载完成后,检查播放队列是否有更新,同步到收藏夹
|
||||||
|
playQueueStore.list.forEach((item) => {
|
||||||
|
if (favourites.isFavourite(item.song.cid)) {
|
||||||
|
favourites.updateSongInFavourites(item.song.cid, item.song)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
playQueueStore.limitPreloadCache()
|
||||||
|
} else {
|
||||||
|
console.error('[Player] preloadNext 不是一个函数')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Player] 预加载失败:', error)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function artistsOrganize(list: string[]) {
|
||||||
|
if (list.length === 0) {
|
||||||
|
return '未知音乐人'
|
||||||
|
}
|
||||||
return list
|
return list
|
||||||
.map((artist) => {
|
.map((artist) => {
|
||||||
return artist
|
return artist
|
||||||
|
@ -80,64 +165,371 @@ function artistsOrganize(list: string[]) {
|
||||||
.join(' / ')
|
.join(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动播放属性判断
|
function setMetadata() {
|
||||||
function isAutoPlay(cid: string) {
|
if ('mediaSession' in navigator) {
|
||||||
// 为了提前缓存播放队列中的歌曲,同时消除两首歌切换时的间隙,因此改用了新的方式来在网页上挂载音频
|
const current = currentTrack.value
|
||||||
// 现在会将队列中每一首歌曲都挂载一个单独的 <audio> 并添加 preload="auto" 属性
|
if (!current) return
|
||||||
// 这样就可以利用浏览器的内置行为来提前缓存队列中的所有歌曲了
|
|
||||||
// 不过,这样就会导致判断到底哪一首歌需要播放就成了难题
|
|
||||||
// 因此就有了这个函数,用于判断哪一个 <audio> 元素需要进行播放
|
|
||||||
// 此函数主要用于 <audio> 元素的 autoplay 属性,以便在专辑或歌单页面点击播放按钮时直接开始播放音乐
|
|
||||||
|
|
||||||
// 先判断是否正在播放
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
if (!playQueue.isPlaying) return false
|
title: current.song.name,
|
||||||
|
artist: artistsOrganize(current.song.artists ?? []),
|
||||||
|
album: current.album?.name,
|
||||||
|
artwork: [
|
||||||
|
{
|
||||||
|
src: current.album?.coverUrl ?? '',
|
||||||
|
sizes: '500x500',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
// 再判断是否是目前曲目
|
navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
|
||||||
if (playQueue.currentTrack.song.cid !== cid) return false
|
navigator.mediaSession.setActionHandler('nexttrack', playNext)
|
||||||
|
|
||||||
return true
|
playQueueStore.duration = player.value?.duration ?? 0
|
||||||
}
|
playQueueStore.currentTime = player.value?.currentTime ?? 0
|
||||||
|
|
||||||
// 获取 audio 元素的 ref
|
|
||||||
function getAudioElement(cid: string): HTMLAudioElement | null {
|
|
||||||
debugPlayer('Getting audio element for:', cid, audioRefs.value)
|
|
||||||
return audioRefs.value[cid] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// audio 元素结束播放事件
|
|
||||||
function endOfPlay() {
|
|
||||||
debugPlayer('结束播放')
|
|
||||||
if (playQueue.loopingMode !== 'single') {
|
|
||||||
const next = playQueue.queue[playQueue.currentIndex + 1]
|
|
||||||
debugPlayer(next.song.cid)
|
|
||||||
debugPlayer(audioRefs.value[next.song.cid])
|
|
||||||
audioRefs.value[next.song.cid].play()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => playQueueStore.updatedCurrentTime,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (player.value) player.value.currentTime = newValue
|
||||||
|
playQueueStore.updatedCurrentTime = null
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAudioRef(cid: string, el: HTMLAudioElement | null) {
|
function playNext() {
|
||||||
if (el) {
|
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
|
||||||
audioRefs.value[cid] = el
|
console.log('at the bottom, pause')
|
||||||
debugPlayer(`Audio element for ${cid} registered`, el)
|
playQueueStore.currentIndex = 0
|
||||||
|
if (playQueueStore.playMode.repeat === 'all') {
|
||||||
|
playQueueStore.currentIndex = 0
|
||||||
|
playQueueStore.isPlaying = true
|
||||||
|
} else {
|
||||||
|
player.value?.pause()
|
||||||
|
playQueueStore.isPlaying = false
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
delete audioRefs.value[cid]
|
playQueueStore.currentIndex++
|
||||||
|
playQueueStore.isPlaying = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playPrevious() {
|
||||||
|
if (
|
||||||
|
player.value &&
|
||||||
|
(player.value.currentTime ?? 0) < 5 &&
|
||||||
|
playQueueStore.currentIndex > 0
|
||||||
|
) {
|
||||||
|
playQueueStore.currentIndex--
|
||||||
|
playQueueStore.isPlaying = true
|
||||||
|
} else {
|
||||||
|
if (player.value) {
|
||||||
|
player.value.currentTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCurrentTime() {
|
||||||
|
playQueueStore.currentTime = player.value?.currentTime ?? 0
|
||||||
|
|
||||||
|
// 智能预加载策略:支持动态配置
|
||||||
|
if (playQueueStore.duration > 0) {
|
||||||
|
const progress = playQueueStore.currentTime / playQueueStore.duration
|
||||||
|
const remainingTime = playQueueStore.duration - playQueueStore.currentTime
|
||||||
|
|
||||||
|
// 从 localStorage 获取配置,如果没有则使用默认值
|
||||||
|
const config = JSON.parse(localStorage.getItem('preloadConfig') || '{}')
|
||||||
|
const preloadTrigger = (config.preloadTrigger || 50) / 100 // 转换为小数
|
||||||
|
const remainingTimeThreshold = config.remainingTimeThreshold || 30
|
||||||
|
|
||||||
|
if (
|
||||||
|
(progress > preloadTrigger || remainingTime < remainingTimeThreshold) &&
|
||||||
|
!playQueueStore.isPreloading
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (typeof playQueueStore.preloadNext === 'function') {
|
||||||
|
playQueueStore.preloadNext()
|
||||||
|
} else {
|
||||||
|
console.error('[Player] preloadNext 不是一个函数')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Player] 智能预加载失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查浏览器是否支持音频可视化
|
||||||
|
const isAudioVisualizationSupported = supportsWebAudioVisualization()
|
||||||
|
console.log('[Player] 音频可视化支持状态:', isAudioVisualizationSupported)
|
||||||
|
|
||||||
|
// 只在支持的浏览器上初始化音频可视化
|
||||||
|
let barHeights = ref<number[]>([0, 0, 0, 0, 0, 0])
|
||||||
|
let connectAudio = (_audio: HTMLAudioElement) => {}
|
||||||
|
let isAnalyzing = ref(false)
|
||||||
|
let error = ref<string | null>(null)
|
||||||
|
|
||||||
|
if (isAudioVisualizationSupported) {
|
||||||
|
console.log('[Player] 初始化 audioVisualizer')
|
||||||
|
const visualizer = audioVisualizer({
|
||||||
|
sensitivity: 1.5,
|
||||||
|
barCount: 6,
|
||||||
|
maxDecibels: -10,
|
||||||
|
bassBoost: 0.8,
|
||||||
|
midBoost: 1.2,
|
||||||
|
trebleBoost: 1.4,
|
||||||
|
threshold: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
barHeights = visualizer.barHeights
|
||||||
|
connectAudio = visualizer.connectAudio
|
||||||
|
isAnalyzing = visualizer.isAnalyzing
|
||||||
|
error = visualizer.error
|
||||||
|
|
||||||
|
console.log('[Player] audioVisualizer 返回值:', {
|
||||||
|
barHeights: barHeights.value,
|
||||||
|
isAnalyzing: isAnalyzing.value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('[Player] 音频可视化被禁用(Safari 或不支持的浏览器)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听播放列表变化
|
||||||
|
watch(
|
||||||
|
() => playQueueStore.list.length,
|
||||||
|
async (newLength) => {
|
||||||
|
console.log('[Player] 播放列表长度变化:', newLength)
|
||||||
|
if (newLength === 0) {
|
||||||
|
console.log('[Player] 播放列表为空,跳过连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待下一帧,确保 audio 元素已经渲染
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (player.value) {
|
||||||
|
if (isAudioVisualizationSupported) {
|
||||||
|
console.log('[Player] 连接音频元素到可视化器')
|
||||||
|
console.log('[Player] 音频元素状态:', {
|
||||||
|
src: player.value.src?.substring(0, 50) + '...',
|
||||||
|
readyState: player.value.readyState,
|
||||||
|
paused: player.value.paused,
|
||||||
|
})
|
||||||
|
connectAudio(player.value)
|
||||||
|
} else {
|
||||||
|
console.log('[Player] 跳过音频可视化连接(不支持的浏览器)')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[Player] ❌ 音频元素不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
playQueueStore.visualizer = barHeights.value
|
||||||
|
|
||||||
|
// 开始预加载第一首歌的下一首
|
||||||
|
setTimeout(() => {
|
||||||
|
playQueueStore.preloadNext()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
// 初始化音量
|
||||||
|
if (player.value) {
|
||||||
|
initializeVolume()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听音频元素变化
|
||||||
|
watch(
|
||||||
|
() => player.value,
|
||||||
|
(audioElement) => {
|
||||||
|
if (audioElement && playQueueStore.list.length > 0 && isAudioVisualizationSupported) {
|
||||||
|
connectAudio(audioElement)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听可视化器数据变化
|
||||||
|
watch(
|
||||||
|
() => barHeights.value,
|
||||||
|
(newHeights) => {
|
||||||
|
playQueueStore.visualizer = newHeights
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听错误
|
||||||
|
watch(
|
||||||
|
() => error.value,
|
||||||
|
(newError) => {
|
||||||
|
if (newError) {
|
||||||
|
console.error('[Player] 可视化器错误:', newError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 切换播放模式
|
||||||
|
watch(
|
||||||
|
() => playQueueStore.playMode.shuffle,
|
||||||
|
(isShuffle) => {
|
||||||
|
if (isShuffle) {
|
||||||
|
const currentIndex = playQueueStore.currentIndex
|
||||||
|
const trackCount = playQueueStore.list.length
|
||||||
|
|
||||||
|
// 1. 已播放部分:不变
|
||||||
|
let shuffledList = [...Array(currentIndex).keys()]
|
||||||
|
|
||||||
|
// 2. 构建待打乱的列表
|
||||||
|
const shuffleSpace = [...Array(trackCount).keys()].filter((index) =>
|
||||||
|
playQueueStore.shuffleCurrent
|
||||||
|
? index >= currentIndex
|
||||||
|
: index > currentIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 随机打乱
|
||||||
|
shuffleSpace.sort(() => Math.random() - 0.5)
|
||||||
|
|
||||||
|
// 4. 如果当前曲目不参与打乱,插入回当前位置(即 currentIndex 处)
|
||||||
|
if (!playQueueStore.shuffleCurrent) {
|
||||||
|
shuffledList.push(currentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 拼接:已播放部分 + 当前(可选)+ 打乱后的剩余部分
|
||||||
|
shuffledList = shuffledList.concat(shuffleSpace)
|
||||||
|
|
||||||
|
// 6. 应用 shuffleList
|
||||||
|
playQueueStore.shuffleList = shuffledList
|
||||||
|
|
||||||
|
// 清除 shuffleCurrent 状态
|
||||||
|
playQueueStore.shuffleCurrent = undefined
|
||||||
|
} else {
|
||||||
|
// 退出随机播放:恢复当前播放曲目的原索引
|
||||||
|
playQueueStore.currentIndex =
|
||||||
|
playQueueStore.shuffleList[playQueueStore.currentIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换播放模式后重新预加载
|
||||||
|
setTimeout(() => {
|
||||||
|
playQueueStore.clearAllPreloadedAudio()
|
||||||
|
playQueueStore.preloadNext()
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function getCurrentTrack() {
|
||||||
|
return currentTrack.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化音量
|
||||||
|
function initializeVolume() {
|
||||||
|
if (player.value) {
|
||||||
|
const savedVolume = localStorage.getItem('audioVolume')
|
||||||
|
if (savedVolume) {
|
||||||
|
const volumeValue = Number.parseFloat(savedVolume)
|
||||||
|
player.value.volume = volumeValue
|
||||||
|
console.log('[Player] 初始化音量:', volumeValue)
|
||||||
|
} else {
|
||||||
|
// 设置默认音量
|
||||||
|
player.value.volume = 1
|
||||||
|
localStorage.setItem('audioVolume', '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听音量变化事件
|
||||||
|
function handleVolumeChange(event: Event) {
|
||||||
|
const target = event.target as HTMLAudioElement
|
||||||
|
if (target) {
|
||||||
|
// 保存音量变化到localStorage
|
||||||
|
localStorage.setItem('audioVolume', target.volume.toString())
|
||||||
|
console.log('[Player] 音量变化:', target.volume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听localStorage中音量的变化,同步到音频元素
|
||||||
|
function syncVolumeFromStorage() {
|
||||||
|
if (player.value) {
|
||||||
|
const savedVolume = localStorage.getItem('audioVolume')
|
||||||
|
if (savedVolume) {
|
||||||
|
const volumeValue = Number.parseFloat(savedVolume)
|
||||||
|
if (player.value.volume !== volumeValue) {
|
||||||
|
player.value.volume = volumeValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期检查音量同步(可选,或者使用storage事件)
|
||||||
|
setInterval(syncVolumeFromStorage, 100)
|
||||||
|
|
||||||
|
// 组件卸载时清理预加载
|
||||||
|
// onUnmounted(() => {
|
||||||
|
// playQueueStore.clearAllPreloadedAudio()
|
||||||
|
// })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-white" v-for="track in playQueue.queue" :key="track.song.cid">
|
<audio :src="currentAudioSrc" ref="playerRef" :autoplay="playQueueStore.isPlaying"
|
||||||
<audio
|
v-if="playQueueStore.list.length !== 0" @volumechange="handleVolumeChange" @ended="() => {
|
||||||
v-if="resourcesUrl[track.song.cid]"
|
if (playQueueStore.playMode.repeat === 'single') { playQueueStore.isPlaying = true }
|
||||||
:src="resourcesUrl[track.song.cid]"
|
else { playNext() }
|
||||||
preload="auto"
|
}" @pause="playQueueStore.isPlaying = false" @play="playQueueStore.isPlaying = true" @playing="() => {
|
||||||
:ref="el => setAudioRef(track.song.cid, el as HTMLAudioElement)"
|
console.log('[Player] 音频开始播放事件')
|
||||||
:autoplay="isAutoPlay(track.song.cid)"
|
playQueueStore.isBuffering = false
|
||||||
@ended="endOfPlay"
|
setMetadata()
|
||||||
@timeupdate=""
|
initializeVolume()
|
||||||
/>
|
}" @waiting="playQueueStore.isBuffering = true" @loadeddata="() => {
|
||||||
{{track.song.cid}}
|
console.log('[Player] 音频数据加载完成')
|
||||||
|
playQueueStore.isBuffering = false
|
||||||
|
initializeVolume()
|
||||||
|
}" @canplay="() => {
|
||||||
|
console.log('[Player] 音频可以播放')
|
||||||
|
playQueueStore.isBuffering = false
|
||||||
|
}" @error="(e) => {
|
||||||
|
console.error('[Player] 音频错误:', e)
|
||||||
|
playQueueStore.isBuffering = false
|
||||||
|
}" crossorigin="anonymous" @timeupdate="updateCurrentTime">
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<!-- 预加载进度指示器(可选显示) -->
|
||||||
|
<!-- <div v-if="playQueueStore.isPreloading"
|
||||||
|
class="fixed top-4 right-4 bg-black/80 text-white px-3 py-1 rounded text-xs z-50">
|
||||||
|
预加载中... {{ Math.round(playQueueStore.preloadProgress) }}%
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="text-white h-9 bg-neutral-800/80 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex gap-2 overflow-hidden select-none"
|
||||||
|
v-if="playQueueStore.list.length !== 0 && route.path !== '/playroom'">
|
||||||
|
<RouterLink to="/playroom">
|
||||||
|
<img :src="getCurrentTrack()?.album?.coverUrl ?? ''" class="rounded-full h-8 w-8 mt-[.0625rem]" />
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink to="/playroom">
|
||||||
|
<div class="flex items-center w-32 h-9">
|
||||||
|
<span class="truncate text-xs">{{ getCurrentTrack()?.song.name }}</span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<button class="h-9 w-12 flex justify-center items-center" @click.stop="() => {
|
||||||
|
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||||
|
}">
|
||||||
|
<div v-if="playQueueStore.isPlaying">
|
||||||
|
<LoadingIndicator v-if="playQueueStore.isBuffering === true" :size="4" />
|
||||||
|
<!-- 在支持的浏览器上显示可视化,否则显示暂停图标 -->
|
||||||
|
<div v-else-if="isAudioVisualizationSupported" class="h-4 flex justify-center items-center gap-[.125rem]">
|
||||||
|
<div class="bg-white/75 w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer"
|
||||||
|
:key="index" :style="{
|
||||||
|
height: `${Math.max(10, bar)}%`
|
||||||
|
}" />
|
||||||
|
</div>
|
||||||
|
<PauseIcon v-else :size="4" />
|
||||||
|
</div>
|
||||||
|
<PlayIcon v-else :size="4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
|
@ -1,382 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
|
||||||
import { usePlayState } from '../stores/usePlayState'
|
|
||||||
import { debugPlayer } from '../utils/debug'
|
|
||||||
import { watch, ref, onMounted } from 'vue'
|
|
||||||
import artistsOrganize from '../utils/artistsOrganize'
|
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
|
||||||
const playState = usePlayState()
|
|
||||||
const playerInstance = ref<WebAudioPlayer | null>(null)
|
|
||||||
|
|
||||||
class WebAudioPlayer {
|
|
||||||
context: AudioContext
|
|
||||||
audioBuffer: { [key: string]: AudioBuffer}
|
|
||||||
dummyAudio: HTMLAudioElement
|
|
||||||
currentTrackStartTime: number
|
|
||||||
currentSource: AudioBufferSourceNode | null
|
|
||||||
nextSource: AudioBufferSourceNode | null
|
|
||||||
reportInterval: ReturnType<typeof setTimeout> | null
|
|
||||||
isInternalTrackChange: boolean
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.context = new window.AudioContext()
|
|
||||||
this.audioBuffer = {}
|
|
||||||
this.currentTrackStartTime = 0
|
|
||||||
this.currentSource = null
|
|
||||||
this.nextSource = null
|
|
||||||
this.reportInterval = null
|
|
||||||
this.isInternalTrackChange = false
|
|
||||||
|
|
||||||
// 创建一个隐藏的 HTML Audio 元素来帮助同步媒体会话状态
|
|
||||||
this.dummyAudio = new Audio()
|
|
||||||
this.dummyAudio.style.display = 'none'
|
|
||||||
this.dummyAudio.loop = true
|
|
||||||
this.dummyAudio.volume = 0.001 // 极小音量
|
|
||||||
// 使用一个很短的静音音频文件,或者生成一个
|
|
||||||
this.createSilentAudioBlob()
|
|
||||||
|
|
||||||
document.body.appendChild(this.dummyAudio)
|
|
||||||
|
|
||||||
this.initMediaSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
createSilentAudioBlob() {
|
|
||||||
// 创建一个1秒的静音WAV文件
|
|
||||||
const sampleRate = 44100
|
|
||||||
const channels = 1
|
|
||||||
const length = sampleRate * 1 // 1秒
|
|
||||||
|
|
||||||
const arrayBuffer = new ArrayBuffer(44 + length * 2)
|
|
||||||
const view = new DataView(arrayBuffer)
|
|
||||||
|
|
||||||
// WAV 文件头
|
|
||||||
const writeString = (offset: number, string: string) => {
|
|
||||||
for (let i = 0; i < string.length; i++) {
|
|
||||||
view.setUint8(offset + i, string.charCodeAt(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeString(0, 'RIFF')
|
|
||||||
view.setUint32(4, 36 + length * 2, true)
|
|
||||||
writeString(8, 'WAVE')
|
|
||||||
writeString(12, 'fmt ')
|
|
||||||
view.setUint32(16, 16, true)
|
|
||||||
view.setUint16(20, 1, true)
|
|
||||||
view.setUint16(22, channels, true)
|
|
||||||
view.setUint32(24, sampleRate, true)
|
|
||||||
view.setUint32(28, sampleRate * 2, true)
|
|
||||||
view.setUint16(32, 2, true)
|
|
||||||
view.setUint16(34, 16, true)
|
|
||||||
writeString(36, 'data')
|
|
||||||
view.setUint32(40, length * 2, true)
|
|
||||||
|
|
||||||
// 静音数据(全零)
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
view.setInt16(44 + i * 2, 0, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([arrayBuffer], { type: 'audio/wav' })
|
|
||||||
this.dummyAudio.src = URL.createObjectURL(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
initMediaSession() {
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => {
|
|
||||||
console.log('Media session: play requested')
|
|
||||||
playState.togglePlay(true)
|
|
||||||
})
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => {
|
|
||||||
console.log('Media session: pause requested')
|
|
||||||
playState.togglePlay(false)
|
|
||||||
})
|
|
||||||
navigator.mediaSession.setActionHandler('stop', () => {
|
|
||||||
console.log('Media session: stop requested')
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓存歌曲并播放
|
|
||||||
async loadResourceAndPlay() {
|
|
||||||
try {
|
|
||||||
debugPlayer("从播放器实例内部获取播放项目:")
|
|
||||||
debugPlayer(`目前播放:${playQueue.currentTrack?.song.cid ?? "空"}`)
|
|
||||||
debugPlayer(`上一首:${playQueue.previousTrack?.song.cid ?? "空"}`)
|
|
||||||
debugPlayer(`下一首:${playQueue.nextTrack?.song.cid ?? "空"}`)
|
|
||||||
|
|
||||||
if (playQueue.queue.length === 0) {
|
|
||||||
// TODO: 如果当前正在播放,则可能需要停止播放
|
|
||||||
playState.reportPlayProgress(0)
|
|
||||||
playState.reportActualPlaying(false)
|
|
||||||
this.currentSource?.stop()
|
|
||||||
this.nextSource?.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playQueue.currentTrack) {
|
|
||||||
await this.loadBuffer(playQueue.currentTrack)
|
|
||||||
this.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playQueue.nextTrack) {
|
|
||||||
await this.loadBuffer(playQueue.nextTrack)
|
|
||||||
if (playState.isPlaying) this.scheduleNextTrack()
|
|
||||||
} else {
|
|
||||||
this.nextSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playQueue.previousTrack)
|
|
||||||
await this.loadBuffer(playQueue.previousTrack)
|
|
||||||
|
|
||||||
debugPlayer("缓存完成")
|
|
||||||
} catch (error) {
|
|
||||||
console.error('播放失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将音频 buffer 加载到缓存空间
|
|
||||||
loadBuffer = async (track: QueueItem) => {
|
|
||||||
if (this.audioBuffer[track.song.cid]) return // 已经缓存了,直接跳
|
|
||||||
const response = await fetch(track.sourceUrl ?? "")
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
const audioBuffer = await this.context.decodeAudioData(arrayBuffer)
|
|
||||||
this.audioBuffer[track.song.cid] = audioBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// 播放
|
|
||||||
play() {
|
|
||||||
if (!playQueue.currentTrack) return
|
|
||||||
|
|
||||||
// 检查是否已经有音频在播放,避免重复播放
|
|
||||||
if (this.currentSource && this.reportInterval) {
|
|
||||||
debugPlayer("已经在播放中,跳过重复播放")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPlayer("开始播放")
|
|
||||||
if (playState.playProgress !== 0) debugPlayer(`已经有所进度!${playState.playProgress}`)
|
|
||||||
|
|
||||||
// 启动 dummyAudio 来向浏览器报告播放状态
|
|
||||||
this.dummyAudio.currentTime = 0
|
|
||||||
this.dummyAudio.play().catch(e => console.warn('DummyAudio play failed:', e))
|
|
||||||
|
|
||||||
// 更新媒体会话状态
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.playbackState = 'playing'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSource = this.context.createBufferSource()
|
|
||||||
this.currentSource.buffer = this.audioBuffer[playQueue.currentTrack.song.cid]
|
|
||||||
this.currentSource.connect(this.context.destination)
|
|
||||||
this.currentSource.start(this.context.currentTime, playState.playProgress)
|
|
||||||
playState.reportActualPlaying(true)
|
|
||||||
this.reportProgress()
|
|
||||||
|
|
||||||
// 开始预先准备无缝播放下一首
|
|
||||||
// 获取下一首歌接入的时间点
|
|
||||||
this.currentTrackStartTime = this.context.currentTime - playState.playProgress
|
|
||||||
if (playQueue.nextTrack && this.audioBuffer[playQueue.nextTrack.song.cid]) this.scheduleNextTrack()
|
|
||||||
|
|
||||||
// 写入当前曲目播放完成后的钩子
|
|
||||||
this.currentSource.onended = () => {
|
|
||||||
debugPlayer("当前歌曲播放结束")
|
|
||||||
if (!!this.reportInterval) {
|
|
||||||
// 页面依然正在回报播放进度,因此为歌曲自然结束
|
|
||||||
debugPlayer("歌曲自然结束")
|
|
||||||
this.onTrackEnded()
|
|
||||||
} else {
|
|
||||||
debugPlayer("用户暂停")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安排下一首歌
|
|
||||||
scheduleNextTrack() {
|
|
||||||
if (this.nextSource !== null) {
|
|
||||||
debugPlayer("下一首已经调度,跳过重复调度")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 处理不同循环逻辑
|
|
||||||
if (!playQueue.nextTrack) return
|
|
||||||
this.nextSource = null
|
|
||||||
const nextTrackStartTime = this.currentTrackStartTime
|
|
||||||
+ this.audioBuffer[playQueue.currentTrack.song.cid].duration
|
|
||||||
debugPlayer(`下一首歌将在 ${nextTrackStartTime} 时间点接入`)
|
|
||||||
|
|
||||||
this.nextSource = this.context.createBufferSource()
|
|
||||||
this.nextSource.buffer = this.audioBuffer[playQueue.nextTrack.song.cid]
|
|
||||||
this.nextSource.connect(this.context.destination)
|
|
||||||
|
|
||||||
this.nextSource.start(nextTrackStartTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始回报播放进度
|
|
||||||
reportProgress() {
|
|
||||||
this.reportInterval = setInterval(() => {
|
|
||||||
const progress = this.context.currentTime - this.currentTrackStartTime
|
|
||||||
playState.reportPlayProgress(progress)
|
|
||||||
playState.reportCurrentTrackDuration(this.audioBuffer[playQueue.currentTrack.song.cid].duration)
|
|
||||||
|
|
||||||
// 向浏览器回报
|
|
||||||
if (('mediaSession' in navigator) && ('setPositionState' in navigator.mediaSession)) {
|
|
||||||
try {
|
|
||||||
navigator.mediaSession.setPositionState({
|
|
||||||
duration: this.audioBuffer[playQueue.currentTrack.song.cid].duration || 0,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
position: progress,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
debugPlayer('媒体会话位置更新失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止回报
|
|
||||||
stopReportProgress() {
|
|
||||||
if (this.reportInterval) clearInterval(this.reportInterval)
|
|
||||||
this.reportInterval = null
|
|
||||||
debugPlayer(this.reportInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
pause() {
|
|
||||||
debugPlayer("尝试暂停播放")
|
|
||||||
debugPlayer(this.currentSource)
|
|
||||||
|
|
||||||
// 暂停 dummyAudio
|
|
||||||
this.dummyAudio.pause()
|
|
||||||
|
|
||||||
// 更新媒体会话状态
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.playbackState = 'paused'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentSource?.stop()
|
|
||||||
this.nextSource?.stop()
|
|
||||||
|
|
||||||
// 清理资源引用
|
|
||||||
this.currentSource = null
|
|
||||||
this.nextSource = null
|
|
||||||
|
|
||||||
playState.reportActualPlaying(false)
|
|
||||||
this.stopReportProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
async onTrackEnded() {
|
|
||||||
// 1. 清理当前状态
|
|
||||||
this.stopReportProgress()
|
|
||||||
playState.reportPlayProgress(0)
|
|
||||||
|
|
||||||
// 2. 检查是否还有下一首
|
|
||||||
if (!this.nextSource) {
|
|
||||||
// 播放结束
|
|
||||||
this.dummyAudio.pause()
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.playbackState = 'none'
|
|
||||||
}
|
|
||||||
playState.reportActualPlaying(false)
|
|
||||||
playState.togglePlay(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 标记为内部切歌,避免 watch 重复触发
|
|
||||||
this.isInternalTrackChange = true
|
|
||||||
|
|
||||||
// 4. 切换到下一首
|
|
||||||
playQueue.continueToNext()
|
|
||||||
this.currentSource = this.nextSource
|
|
||||||
this.nextSource = null
|
|
||||||
|
|
||||||
// 5. 重新计算时间轴并启动进度报告
|
|
||||||
this.currentTrackStartTime = this.context.currentTime
|
|
||||||
this.reportProgress()
|
|
||||||
|
|
||||||
// 6. 为新的当前播放源设置 onended 处理程序
|
|
||||||
if (this.currentSource) {
|
|
||||||
this.currentSource.onended = () => {
|
|
||||||
debugPlayer("当前歌曲播放结束")
|
|
||||||
if (!!this.reportInterval) {
|
|
||||||
debugPlayer("歌曲自然结束")
|
|
||||||
this.onTrackEnded()
|
|
||||||
} else {
|
|
||||||
debugPlayer("用户暂停")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 处理下下首
|
|
||||||
if (playQueue.nextTrack) {
|
|
||||||
debugPlayer("处理下下一首歌")
|
|
||||||
if (this.audioBuffer[playQueue.nextTrack.song.cid]) {
|
|
||||||
this.scheduleNextTrack()
|
|
||||||
} else {
|
|
||||||
await this.loadBuffer(playQueue.nextTrack)
|
|
||||||
if (playState.actualPlaying) this.scheduleNextTrack()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
this.isInternalTrackChange = false
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 Web Audio 播放器
|
|
||||||
onMounted(() => {
|
|
||||||
playerInstance.value = new WebAudioPlayer()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听当前曲目变化,只在需要时加载和播放
|
|
||||||
watch(() => playQueue.currentTrack, (newTrack, oldTrack) => {
|
|
||||||
debugPlayer(`检测到当前播放曲目更新`)
|
|
||||||
|
|
||||||
if (newTrack) {
|
|
||||||
// 更新媒体会话元数据
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.playbackState = playState.isPlaying ? 'playing' : 'paused'
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: newTrack.song.name,
|
|
||||||
artist: artistsOrganize(newTrack.song.artistes ?? newTrack.song.artists ?? []),
|
|
||||||
album: newTrack.album?.name,
|
|
||||||
artwork: [
|
|
||||||
{ src: newTrack.album?.coverUrl ?? "", sizes: '500x500', type: 'image/png' },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是内部切歌(onTrackEnded 触发的),不需要重新播放
|
|
||||||
// 只有在用户主动切歌或首次播放时才调用 loadResourceAndPlay
|
|
||||||
if (!playerInstance.value?.isInternalTrackChange) {
|
|
||||||
playerInstance.value?.loadResourceAndPlay()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => playState.isPlaying, () => {
|
|
||||||
if (!playState.isPlaying) {
|
|
||||||
// 触发暂停
|
|
||||||
playerInstance.value?.pause()
|
|
||||||
} else {
|
|
||||||
// 恢复音频
|
|
||||||
playerInstance.value?.play()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => playQueue.queue, () => {
|
|
||||||
debugPlayer("检测到播放列表更新")
|
|
||||||
// 如果新列表是空的,那么代表用户选择彻底换一个新的播放列表进行播放
|
|
||||||
if (playQueue.queue.length === 0) {
|
|
||||||
debugPlayer("触发暂停播放")
|
|
||||||
// 此时需要暂停播放
|
|
||||||
playerInstance.value?.pause()
|
|
||||||
playState.togglePlay(false)
|
|
||||||
playState.reportPlayProgress(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
</template>
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
||||||
import XIcon from '../assets/icons/x.vue'
|
import XIcon from '../assets/icons/x.vue'
|
||||||
import { usePreferences } from '../stores/usePreferences'
|
import { usePreferences } from '../stores/usePreferences'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
|
@ -90,8 +90,6 @@ import { onMounted, ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { usePlayState } from '../stores/usePlayState'
|
|
||||||
import { debugLyrics } from '../utils/debug'
|
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
interface LyricsLine {
|
interface LyricsLine {
|
||||||
|
@ -109,7 +107,6 @@ interface GapLine {
|
||||||
}
|
}
|
||||||
|
|
||||||
const playQueueStore = usePlayQueueStore()
|
const playQueueStore = usePlayQueueStore()
|
||||||
const playState = usePlayState()
|
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
|
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
|
||||||
|
@ -139,7 +136,7 @@ const props = defineProps<{
|
||||||
// 滚动指示器相关计算
|
// 滚动指示器相关计算
|
||||||
const scrollIndicatorHeight = computed(() => {
|
const scrollIndicatorHeight = computed(() => {
|
||||||
if (parsedLyrics.value.length === 0) return 0
|
if (parsedLyrics.value.length === 0) return 0
|
||||||
return Math.max(10, (100 / parsedLyrics.value.length) * 5) // 显示大约5行的比例
|
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 显示大约5行的比例
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollIndicatorPosition = computed(() => {
|
const scrollIndicatorPosition = computed(() => {
|
||||||
|
@ -147,11 +144,7 @@ const scrollIndicatorPosition = computed(() => {
|
||||||
const progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
|
const progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
|
||||||
const containerHeight = lyricsContainer.value?.clientHeight || 400
|
const containerHeight = lyricsContainer.value?.clientHeight || 400
|
||||||
const indicatorTrackHeight = containerHeight / 2 // 指示器轨道高度
|
const indicatorTrackHeight = containerHeight / 2 // 指示器轨道高度
|
||||||
return (
|
return progress * (indicatorTrackHeight - (scrollIndicatorHeight.value / 100 * indicatorTrackHeight))
|
||||||
progress *
|
|
||||||
(indicatorTrackHeight -
|
|
||||||
(scrollIndicatorHeight.value / 100) * indicatorTrackHeight)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置行引用
|
// 设置行引用
|
||||||
|
@ -162,19 +155,15 @@ function setLineRef(el: HTMLElement | null, index: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 歌词解析函数
|
// 歌词解析函数
|
||||||
function parseLyrics(
|
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
|
||||||
lrcText: string,
|
if (!lrcText) return [
|
||||||
minGapDuration: number = 5,
|
{
|
||||||
): (LyricsLine | GapLine)[] {
|
type: 'lyric',
|
||||||
if (!lrcText)
|
time: 0,
|
||||||
return [
|
text: '',
|
||||||
{
|
originalTime: '[00:00]'
|
||||||
type: 'lyric',
|
}
|
||||||
time: 0,
|
]
|
||||||
text: '',
|
|
||||||
originalTime: '[00:00]',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const lines = lrcText.split('\n')
|
const lines = lrcText.split('\n')
|
||||||
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
const tempParsedLines: (LyricsLine | GapLine)[] = []
|
||||||
|
@ -199,13 +188,13 @@ function parseLyrics(
|
||||||
type: 'lyric',
|
type: 'lyric',
|
||||||
time: totalSeconds,
|
time: totalSeconds,
|
||||||
text: text,
|
text: text,
|
||||||
originalTime: match[0],
|
originalTime: match[0]
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
tempParsedLines.push({
|
tempParsedLines.push({
|
||||||
type: 'gap',
|
type: 'gap',
|
||||||
time: totalSeconds,
|
time: totalSeconds,
|
||||||
originalTime: match[0],
|
originalTime: match[0]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -214,18 +203,14 @@ function parseLyrics(
|
||||||
tempParsedLines.sort((a, b) => a.time - b.time)
|
tempParsedLines.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
const finalLines: (LyricsLine | GapLine)[] = []
|
const finalLines: (LyricsLine | GapLine)[] = []
|
||||||
const lyricLines = tempParsedLines.filter(
|
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
|
||||||
(line) => line.type === 'lyric',
|
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
|
||||||
) as LyricsLine[]
|
|
||||||
const gapLines = tempParsedLines.filter(
|
|
||||||
(line) => line.type === 'gap',
|
|
||||||
) as GapLine[]
|
|
||||||
|
|
||||||
if (lyricLines.length === 0) return tempParsedLines
|
if (lyricLines.length === 0) return tempParsedLines
|
||||||
|
|
||||||
for (let i = 0; i < gapLines.length; i++) {
|
for (let i = 0; i < gapLines.length; i++) {
|
||||||
const gapLine = gapLines[i]
|
const gapLine = gapLines[i]
|
||||||
const nextLyricLine = lyricLines.find((lyric) => lyric.time > gapLine.time)
|
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
|
||||||
|
|
||||||
if (nextLyricLine) {
|
if (nextLyricLine) {
|
||||||
const duration = nextLyricLine.time - gapLine.time
|
const duration = nextLyricLine.time - gapLine.time
|
||||||
|
@ -244,7 +229,7 @@ function parseLyrics(
|
||||||
type: 'lyric',
|
type: 'lyric',
|
||||||
time: 0,
|
time: 0,
|
||||||
text: '',
|
text: '',
|
||||||
originalTime: '[00:00]',
|
originalTime: '[00:00]'
|
||||||
})
|
})
|
||||||
return sortedLines
|
return sortedLines
|
||||||
}
|
}
|
||||||
|
@ -267,12 +252,7 @@ function findCurrentLineIndex(time: number): number {
|
||||||
|
|
||||||
// 使用 GSAP 滚动到指定行
|
// 使用 GSAP 滚动到指定行
|
||||||
function scrollToLine(lineIndex: number, smooth = true) {
|
function scrollToLine(lineIndex: number, smooth = true) {
|
||||||
if (
|
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
|
||||||
!lyricsContainer.value ||
|
|
||||||
!lyricsWrapper.value ||
|
|
||||||
!lineRefs.value[lineIndex]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const container = lyricsContainer.value
|
const container = lyricsContainer.value
|
||||||
const wrapper = lyricsWrapper.value
|
const wrapper = lyricsWrapper.value
|
||||||
|
@ -296,10 +276,10 @@ function scrollToLine(lineIndex: number, smooth = true) {
|
||||||
scrollTween = gsap.to(wrapper, {
|
scrollTween = gsap.to(wrapper, {
|
||||||
y: targetY,
|
y: targetY,
|
||||||
duration: 0.8,
|
duration: 0.8,
|
||||||
ease: 'power2.out',
|
ease: "power2.out",
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
scrollTween = null
|
scrollTween = null
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
gsap.set(wrapper, { y: targetY })
|
gsap.set(wrapper, { y: targetY })
|
||||||
|
@ -324,7 +304,7 @@ function highlightCurrentLine(lineIndex: number) {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
opacity: index < lineIndex ? 0.6 : 0.4,
|
opacity: index < lineIndex ? 0.6 : 0.4,
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
ease: 'power2.out',
|
ease: "power2.out"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -334,10 +314,10 @@ function highlightCurrentLine(lineIndex: number) {
|
||||||
scale: 1.05,
|
scale: 1.05,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
duration: 0.2,
|
duration: 0.2,
|
||||||
ease: 'back.out(1.7)',
|
ease: "back.out(1.7)",
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
highlightTween = null
|
highlightTween = null
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,7 +334,7 @@ function handleWheel(event: WheelEvent) {
|
||||||
scrollTween.kill()
|
scrollTween.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentY = gsap.getProperty(lyricsWrapper.value, 'y') as number
|
const currentY = gsap.getProperty(lyricsWrapper.value, "y") as number
|
||||||
const newY = currentY - event.deltaY * 0.5
|
const newY = currentY - event.deltaY * 0.5
|
||||||
|
|
||||||
// 修正滚动范围计算
|
// 修正滚动范围计算
|
||||||
|
@ -367,7 +347,7 @@ function handleWheel(event: WheelEvent) {
|
||||||
gsap.to(lyricsWrapper.value, {
|
gsap.to(lyricsWrapper.value, {
|
||||||
y: limitedY,
|
y: limitedY,
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
ease: 'power2.out',
|
ease: "power2.out"
|
||||||
})
|
})
|
||||||
|
|
||||||
if (userScrollTimeout) {
|
if (userScrollTimeout) {
|
||||||
|
@ -387,7 +367,7 @@ function handleWheel(event: WheelEvent) {
|
||||||
// 处理歌词行点击
|
// 处理歌词行点击
|
||||||
function handleLineClick(line: LyricsLine | GapLine, index: number) {
|
function handleLineClick(line: LyricsLine | GapLine, index: number) {
|
||||||
if (line.type === 'lyric') {
|
if (line.type === 'lyric') {
|
||||||
debugLyrics('跳转到时间点', line.time)
|
console.log('Jump to time:', line.time)
|
||||||
// 这里可以发出事件让父组件处理音频跳转
|
// 这里可以发出事件让父组件处理音频跳转
|
||||||
// emit('seek', line.time)
|
// emit('seek', line.time)
|
||||||
}
|
}
|
||||||
|
@ -397,16 +377,15 @@ function handleLineClick(line: LyricsLine | GapLine, index: number) {
|
||||||
|
|
||||||
// 添加点击反馈动画
|
// 添加点击反馈动画
|
||||||
if (lineRefs.value[index]) {
|
if (lineRefs.value[index]) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(lineRefs.value[index],
|
||||||
lineRefs.value[index],
|
|
||||||
{ scale: 1 },
|
{ scale: 1 },
|
||||||
{
|
{
|
||||||
scale: 1.1,
|
scale: 1.1,
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: 1,
|
repeat: 1,
|
||||||
ease: 'power2.inOut',
|
ease: "power2.inOut"
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -418,16 +397,15 @@ function toggleAutoScroll() {
|
||||||
|
|
||||||
// 按钮点击动画
|
// 按钮点击动画
|
||||||
if (controlPanel.value) {
|
if (controlPanel.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(controlPanel.value.children[0],
|
||||||
controlPanel.value.children[0],
|
|
||||||
{ scale: 1 },
|
{ scale: 1 },
|
||||||
{
|
{
|
||||||
scale: 0.95,
|
scale: 0.95,
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: 1,
|
repeat: 1,
|
||||||
ease: 'power2.inOut',
|
ease: "power2.inOut"
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -450,7 +428,7 @@ function resetScroll() {
|
||||||
gsap.to(lyricsWrapper.value, {
|
gsap.to(lyricsWrapper.value, {
|
||||||
y: 0,
|
y: 0,
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
ease: 'power2.out',
|
ease: "power2.out"
|
||||||
})
|
})
|
||||||
|
|
||||||
autoScroll.value = true
|
autoScroll.value = true
|
||||||
|
@ -458,16 +436,15 @@ function resetScroll() {
|
||||||
|
|
||||||
// 按钮点击动画
|
// 按钮点击动画
|
||||||
if (controlPanel.value) {
|
if (controlPanel.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(controlPanel.value.children[1],
|
||||||
controlPanel.value.children[1],
|
|
||||||
{ scale: 1 },
|
{ scale: 1 },
|
||||||
{
|
{
|
||||||
scale: 0.95,
|
scale: 0.95,
|
||||||
duration: 0.1,
|
duration: 0.1,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: 1,
|
repeat: 1,
|
||||||
ease: 'power2.inOut',
|
ease: "power2.inOut"
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -484,7 +461,7 @@ function getGapDotOpacities(line: GapLine) {
|
||||||
const duration = line.duration ?? 0
|
const duration = line.duration ?? 0
|
||||||
if (duration <= 0) return [0.3, 0.3, 0.3]
|
if (duration <= 0) return [0.3, 0.3, 0.3]
|
||||||
// 当前播放时间
|
// 当前播放时间
|
||||||
const now = playState.playProgress
|
const now = playQueueStore.currentTime
|
||||||
// gap 起止时间
|
// gap 起止时间
|
||||||
const start = line.time
|
const start = line.time
|
||||||
// 计算进度
|
// 计算进度
|
||||||
|
@ -493,87 +470,79 @@ function getGapDotOpacities(line: GapLine) {
|
||||||
// 每个圆点的阈值
|
// 每个圆点的阈值
|
||||||
const thresholds = [1 / 4, 2 / 4, 3 / 4]
|
const thresholds = [1 / 4, 2 / 4, 3 / 4]
|
||||||
// 透明度从 0.3 到 1
|
// 透明度从 0.3 到 1
|
||||||
return thresholds.map((t) =>
|
return thresholds.map(t => progress >= t ? 1 : progress >= t - 1 / 3 ? 0.6 : 0.3)
|
||||||
progress >= t ? 1 : progress >= t - 1 / 3 ? 0.6 : 0.3,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听播放时间变化
|
// 监听播放时间变化
|
||||||
watch(
|
watch(() => playQueueStore.currentTime, (time) => {
|
||||||
() => playState.playProgress,
|
const newIndex = findCurrentLineIndex(time)
|
||||||
(time) => {
|
|
||||||
const newIndex = findCurrentLineIndex(time)
|
|
||||||
|
|
||||||
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
|
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
|
||||||
currentLineIndex.value = newIndex
|
currentLineIndex.value = newIndex
|
||||||
|
|
||||||
// 高亮动画
|
// 高亮动画
|
||||||
highlightCurrentLine(newIndex)
|
highlightCurrentLine(newIndex)
|
||||||
|
|
||||||
// 自动滚动
|
// 自动滚动
|
||||||
if (autoScroll.value && !userScrolling.value) {
|
if (autoScroll.value && !userScrolling.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scrollToLine(newIndex, true)
|
scrollToLine(newIndex, true)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
|
|
||||||
// 监听歌词源变化
|
// 监听歌词源变化
|
||||||
watch(
|
watch(() => props.lrcSrc, async (newSrc) => {
|
||||||
() => props.lrcSrc,
|
console.log('Loading new lyrics from:', newSrc)
|
||||||
async (newSrc) => {
|
// 重置状态
|
||||||
debugLyrics('加载新歌词', newSrc)
|
currentLineIndex.value = -1
|
||||||
// 重置状态
|
lineRefs.value = []
|
||||||
currentLineIndex.value = -1
|
|
||||||
lineRefs.value = []
|
|
||||||
|
|
||||||
// 停止所有动画
|
// 停止所有动画
|
||||||
if (scrollTween) scrollTween.kill()
|
if (scrollTween) scrollTween.kill()
|
||||||
if (highlightTween) highlightTween.kill()
|
if (highlightTween) highlightTween.kill()
|
||||||
|
|
||||||
if (newSrc) {
|
if (newSrc) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 加载动画
|
// 加载动画
|
||||||
if (loadingIndicator.value) {
|
if (loadingIndicator.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(loadingIndicator.value,
|
||||||
loadingIndicator.value,
|
{ opacity: 0, scale: 0.8 },
|
||||||
{ opacity: 0, scale: 0.8 },
|
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" }
|
||||||
{ opacity: 1, scale: 1, duration: 0.3, ease: 'back.out(1.7)' },
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(newSrc)
|
const response = await axios.get(newSrc)
|
||||||
parsedLyrics.value = parseLyrics(response.data)
|
parsedLyrics.value = parseLyrics(response.data)
|
||||||
debugLyrics('歌词解析完成', parsedLyrics.value)
|
console.log('Parsed lyrics:', parsedLyrics.value)
|
||||||
|
|
||||||
autoScroll.value = true
|
autoScroll.value = true
|
||||||
userScrolling.value = false
|
userScrolling.value = false
|
||||||
|
|
||||||
// 重置滚动位置
|
|
||||||
if (lyricsWrapper.value) {
|
|
||||||
gsap.set(lyricsWrapper.value, { y: 0 })
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
debugLyrics('歌词加载失败', error)
|
|
||||||
parsedLyrics.value = []
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsedLyrics.value = []
|
|
||||||
|
|
||||||
// 重置滚动位置
|
// 重置滚动位置
|
||||||
if (lyricsWrapper.value) {
|
if (lyricsWrapper.value) {
|
||||||
gsap.set(lyricsWrapper.value, { y: 0 })
|
gsap.set(lyricsWrapper.value, { y: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load lyrics:', error)
|
||||||
|
parsedLyrics.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
{ immediate: true },
|
parsedLyrics.value = []
|
||||||
)
|
|
||||||
|
// 重置滚动位置
|
||||||
|
if (lyricsWrapper.value) {
|
||||||
|
gsap.set(lyricsWrapper.value, { y: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
|
||||||
// 页面焦点处理函数变量声明
|
// 页面焦点处理函数变量声明
|
||||||
let handleVisibilityChange: (() => void) | null = null
|
let handleVisibilityChange: (() => void) | null = null
|
||||||
|
@ -589,14 +558,10 @@ function setupPageFocusHandlers() {
|
||||||
// 页面重新获得焦点时恢复并重新同步
|
// 页面重新获得焦点时恢复并重新同步
|
||||||
if (scrollTween && scrollTween.paused()) scrollTween.resume()
|
if (scrollTween && scrollTween.paused()) scrollTween.resume()
|
||||||
if (highlightTween && highlightTween.paused()) highlightTween.resume()
|
if (highlightTween && highlightTween.paused()) highlightTween.resume()
|
||||||
|
|
||||||
// 重新同步歌词位置
|
// 重新同步歌词位置
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (
|
if (currentLineIndex.value >= 0 && autoScroll.value && !userScrolling.value) {
|
||||||
currentLineIndex.value >= 0 &&
|
|
||||||
autoScroll.value &&
|
|
||||||
!userScrolling.value
|
|
||||||
) {
|
|
||||||
scrollToLine(currentLineIndex.value, false) // 不使用动画,直接定位
|
scrollToLine(currentLineIndex.value, false) // 不使用动画,直接定位
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -613,10 +578,9 @@ onMounted(() => {
|
||||||
|
|
||||||
// 控制面板入场动画
|
// 控制面板入场动画
|
||||||
if (controlPanel.value) {
|
if (controlPanel.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(controlPanel.value,
|
||||||
controlPanel.value,
|
|
||||||
{ opacity: 0, x: 20 },
|
{ opacity: 0, x: 20 },
|
||||||
{ opacity: 0, x: 0, duration: 0.2, ease: 'power2.out', delay: 0.2 },
|
{ opacity: 0, x: 0, duration: 0.2, ease: "power2.out", delay: 0.2 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -624,16 +588,15 @@ onMounted(() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
lineRefs.value.forEach((el, index) => {
|
lineRefs.value.forEach((el, index) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(el,
|
||||||
el,
|
|
||||||
{ opacity: 0, y: 30 },
|
{ opacity: 0, y: 30 },
|
||||||
{
|
{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
duration: 0.2,
|
duration: 0.2,
|
||||||
ease: 'power2.out',
|
ease: "power2.out",
|
||||||
delay: index * 0.1,
|
delay: index * 0.1
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -645,7 +608,7 @@ onUnmounted(() => {
|
||||||
if (scrollTween) scrollTween.kill()
|
if (scrollTween) scrollTween.kill()
|
||||||
if (highlightTween) highlightTween.kill()
|
if (highlightTween) highlightTween.kill()
|
||||||
if (userScrollTimeout) clearTimeout(userScrollTimeout)
|
if (userScrollTimeout) clearTimeout(userScrollTimeout)
|
||||||
|
|
||||||
// 清理页面焦点事件监听器
|
// 清理页面焦点事件监听器
|
||||||
if (handleVisibilityChange) {
|
if (handleVisibilityChange) {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
@ -657,10 +620,7 @@ defineExpose({
|
||||||
scrollToLine,
|
scrollToLine,
|
||||||
toggleAutoScroll,
|
toggleAutoScroll,
|
||||||
resetScroll,
|
resetScroll,
|
||||||
getCurrentLine: () =>
|
getCurrentLine: () => currentLineIndex.value >= 0 ? parsedLyrics.value[currentLineIndex.value] : null
|
||||||
currentLineIndex.value >= 0
|
|
||||||
? parsedLyrics.value[currentLineIndex.value]
|
|
||||||
: null,
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -4,17 +4,16 @@ import { ref } from 'vue'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { useFavourites } from '../stores/useFavourites'
|
import { useFavourites } from '../stores/useFavourites'
|
||||||
import { debugUI } from '../utils/debug'
|
|
||||||
|
|
||||||
import QueueAddIcon from '../assets/icons/queueadd.vue'
|
import QueueAddIcon from '../assets/icons/queueadd.vue'
|
||||||
import StarEmptyIcon from '../assets/icons/starempty.vue'
|
import StarEmptyIcon from '../assets/icons/starempty.vue'
|
||||||
import StarFilledIcon from '../assets/icons/starfilled.vue'
|
import StarFilledIcon from '../assets/icons/starfilled.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
album?: Album
|
album?: Album,
|
||||||
track: Song
|
track: Song,
|
||||||
index: number
|
index: number,
|
||||||
playfrom: (index: number) => void
|
playfrom: (index: number) => void,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const hover = ref(false)
|
const hover = ref(false)
|
||||||
|
@ -24,8 +23,8 @@ const toast = useToast()
|
||||||
const favourites = useFavourites()
|
const favourites = useFavourites()
|
||||||
|
|
||||||
function appendToQueue() {
|
function appendToQueue() {
|
||||||
debugUI('添加歌曲到队列')
|
console.log('aaa')
|
||||||
const queue = playQueueStore.list
|
let queue = playQueueStore.list
|
||||||
queue.push({
|
queue.push({
|
||||||
song: props.track,
|
song: props.track,
|
||||||
album: props.album,
|
album: props.album,
|
||||||
|
@ -42,7 +41,7 @@ function appendToQueue() {
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="flex justify-between align-center gap-4 text-left px-2 h-[2.75rem] hover:bg-neutral-600/40 odd:bg-netural-600/20 relative overflow-hidden bg-neutral-800/20 odd:bg-neutral-800/40 transition-all"
|
class="flex justify-between align-center gap-4 text-left px-2 h-[2.75rem] hover:bg-neutral-600/40 odd:bg-netural-600/20 relative overflow-hidden bg-neutral-800/20 odd:bg-neutral-800/40 transition-all"
|
||||||
@click="playfrom(index)" @mouseenter="() => { hover = true; debugUI('鼠标悬停在歌曲项') }" @mouseleave="hover = false">
|
@click="playfrom(index)" @mouseenter="() => { hover = true; console.log('aaa') }" @mouseleave="hover = false">
|
||||||
|
|
||||||
<span class="text-[3.7rem] text-white/10 absolute left-0 top-[-1.4rem] track_num">{{ index + 1 }}</span>
|
<span class="text-[3.7rem] text-white/10 absolute left-0 top-[-1.4rem] track_num">{{ index + 1 }}</span>
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ onMounted(async () => {
|
||||||
if (!updatePopupStore.isLoaded) {
|
if (!updatePopupStore.isLoaded) {
|
||||||
await updatePopupStore.initializeUpdatePopup()
|
await updatePopupStore.initializeUpdatePopup()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要显示更新弹窗
|
// 检查是否需要显示更新弹窗
|
||||||
const shouldShow = await updatePopupStore.shouldShowUpdatePopup()
|
const shouldShow = await updatePopupStore.shouldShowUpdatePopup()
|
||||||
showPopup.value = shouldShow
|
showPopup.value = shouldShow
|
||||||
|
|
15
src/main.ts
15
src/main.ts
|
@ -10,21 +10,20 @@ import HomePage from './pages/Home.vue'
|
||||||
import AlbumDetailView from './pages/AlbumDetail.vue'
|
import AlbumDetailView from './pages/AlbumDetail.vue'
|
||||||
import Playroom from './pages/Playroom.vue'
|
import Playroom from './pages/Playroom.vue'
|
||||||
import Library from './pages/Library.vue'
|
import Library from './pages/Library.vue'
|
||||||
import Debug from './pages/Debug.vue'
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: HomePage },
|
{ path: '/', component: HomePage },
|
||||||
{ path: '/albums/:albumId', component: AlbumDetailView },
|
{ path: '/albums/:albumId', component: AlbumDetailView },
|
||||||
{ path: '/playroom', component: Playroom },
|
{ path: '/playroom', component: Playroom },
|
||||||
{ path: '/library', component: Library },
|
{ path: '/library', component: Library }
|
||||||
{ path: '/debug', component: Debug}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes,
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
createApp(App).use(router).use(pinia).use(ToastPlugin).mount('#app')
|
createApp(App).use(router).use(pinia).use(ToastPlugin).mount('#app')
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { useRoute } from 'vue-router'
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { artistsOrganize } from '../utils'
|
import { artistsOrganize } from '../utils'
|
||||||
import TrackItem from '../components/TrackItem.vue'
|
import TrackItem from '../components/TrackItem.vue'
|
||||||
import { debugUI } from '../utils/debug'
|
|
||||||
|
|
||||||
import PlayIcon from '../assets/icons/play.vue'
|
import PlayIcon from '../assets/icons/play.vue'
|
||||||
import StarEmptyIcon from '../assets/icons/starempty.vue'
|
import StarEmptyIcon from '../assets/icons/starempty.vue'
|
||||||
|
@ -22,35 +21,27 @@ onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
let res = await apis.getAlbum(albumId as string)
|
let res = await apis.getAlbum(albumId as string)
|
||||||
for (const track in res.songs) {
|
for (const track in res.songs) {
|
||||||
res.songs[parseInt(track)] = await apis.getSong(
|
res.songs[parseInt(track)] = await apis.getSong(res.songs[parseInt(track)].cid)
|
||||||
res.songs[parseInt(track)].cid,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
album.value = res
|
album.value = res
|
||||||
debugUI('专辑详情加载完成', res)
|
console.log(res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugUI('专辑详情加载失败', error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function playTheAlbum(from: number = 0) {
|
function playTheAlbum(from: number = 0) {
|
||||||
if (playQueue.queueReplaceLock) {
|
if (playQueue.queueReplaceLock) {
|
||||||
if (
|
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
|
||||||
!confirm(
|
|
||||||
'当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
playQueue.queueReplaceLock = false
|
playQueue.queueReplaceLock = false
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPlayQueue = []
|
let newPlayQueue = []
|
||||||
for (const track of album.value?.songs ?? []) {
|
for (const track of album.value?.songs ?? []) {
|
||||||
debugUI('添加歌曲到播放队列', track)
|
console.log(track)
|
||||||
newPlayQueue.push({
|
newPlayQueue.push({
|
||||||
song: track,
|
song: track,
|
||||||
album: album.value,
|
album: album.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
playQueue.playMode.shuffle = false
|
playQueue.playMode.shuffle = false
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import apis from '../apis'
|
|
||||||
import { debugUI } from '../utils/debug'
|
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
|
||||||
import { usePlayState } from '../stores/usePlayState'
|
|
||||||
|
|
||||||
const playQueue = usePlayQueueStore()
|
|
||||||
const playState = usePlayState()
|
|
||||||
|
|
||||||
async function playTheList() {
|
|
||||||
debugUI("开始播放")
|
|
||||||
const res = await apis.getAlbum("8936")
|
|
||||||
let newQueue: QueueItem[] = []
|
|
||||||
for (const track of res.songs ?? []) {
|
|
||||||
newQueue[newQueue.length] = {
|
|
||||||
song: track,
|
|
||||||
album: res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
playQueue.replaceQueue(newQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pauseOrResume() {
|
|
||||||
playState.togglePlay()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="text-white flex justify-center items-center min-h-screen flex-col">
|
|
||||||
<button class="bg-white/20 px-2 py-1" @click="playTheList">开始播放</button>
|
|
||||||
<div>当前播放队列里有 {{ playQueue.queue.length }} 首歌</div>
|
|
||||||
<button class="bg-white/20 px-2 py-1" @click="pauseOrResume">播放/暂停</button>
|
|
||||||
<div>播放进度:{{ Math.floor(playState.playProgress) }} / {{ Math.floor(playState.trackDuration) }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -6,7 +6,7 @@ import AlbumDetailDialog from '../components/AlbumDetailDialog.vue'
|
||||||
const albums = ref([] as AlbumList)
|
const albums = ref([] as AlbumList)
|
||||||
|
|
||||||
const presentAlbumDetailDialog = ref(false)
|
const presentAlbumDetailDialog = ref(false)
|
||||||
const presentedAlbum = ref('')
|
const presentedAlbum = ref("")
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await apis.getAlbums()
|
const res = await apis.getAlbums()
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
import StarFilledIcon from '../assets/icons/starfilled.vue'
|
import StarFilledIcon from '../assets/icons/starfilled.vue'
|
||||||
import PlayIcon from '../assets/icons/play.vue'
|
import PlayIcon from '../assets/icons/play.vue'
|
||||||
import ShuffleIcon from '../assets/icons/shuffle.vue'
|
import ShuffleIcon from '../assets/icons/shuffle.vue'
|
||||||
import { debugUI } from '../utils/debug'
|
|
||||||
|
|
||||||
import { useFavourites } from '../stores/useFavourites'
|
import { useFavourites } from '../stores/useFavourites'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
@ -15,18 +14,10 @@ const playQueueStore = usePlayQueueStore()
|
||||||
const currentList = ref<'favourites' | number>('favourites')
|
const currentList = ref<'favourites' | number>('favourites')
|
||||||
|
|
||||||
function playTheList(list: 'favourites' | number, playFrom: number = 0) {
|
function playTheList(list: 'favourites' | number, playFrom: number = 0) {
|
||||||
if (playFrom < 0 || playFrom >= favourites.favouritesCount) {
|
if (playFrom < 0 || playFrom >= favourites.favouritesCount) { playFrom = 0 }
|
||||||
playFrom = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usePlayQueueStore().queueReplaceLock) {
|
if (usePlayQueueStore().queueReplaceLock) {
|
||||||
if (
|
if (!confirm("当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?")) { return }
|
||||||
!confirm(
|
|
||||||
'当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
usePlayQueueStore().queueReplaceLock = false
|
usePlayQueueStore().queueReplaceLock = false
|
||||||
}
|
}
|
||||||
playQueueStore.list = []
|
playQueueStore.list = []
|
||||||
|
@ -34,9 +25,9 @@ function playTheList(list: 'favourites' | number, playFrom: number = 0) {
|
||||||
if (list === 'favourites') {
|
if (list === 'favourites') {
|
||||||
if (favourites.favouritesCount === 0) return
|
if (favourites.favouritesCount === 0) return
|
||||||
|
|
||||||
let newPlayQueue = favourites.favourites.map((item) => ({
|
let newPlayQueue = favourites.favourites.map(item => ({
|
||||||
song: item.song,
|
song: item.song,
|
||||||
album: item.album,
|
album: item.album
|
||||||
}))
|
}))
|
||||||
playQueueStore.list = newPlayQueue.slice().reverse()
|
playQueueStore.list = newPlayQueue.slice().reverse()
|
||||||
playQueueStore.currentIndex = playFrom
|
playQueueStore.currentIndex = playFrom
|
||||||
|
@ -58,6 +49,7 @@ function shuffle(list: 'favourites' | number) {
|
||||||
playQueueStore.isBuffering = true
|
playQueueStore.isBuffering = true
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -132,7 +124,7 @@ function shuffle(list: 'favourites' | number) {
|
||||||
<div class="flex flex-col gap-2 mt-4 mr-8 pb-8">
|
<div class="flex flex-col gap-2 mt-4 mr-8 pb-8">
|
||||||
<PlayListItem v-for="(item, index) in favourites.favourites.slice().reverse()" :key="item.song.cid" :item="item"
|
<PlayListItem v-for="(item, index) in favourites.favourites.slice().reverse()" :key="item.song.cid" :item="item"
|
||||||
:index="index" @play="(playFrom) => {
|
:index="index" @play="(playFrom) => {
|
||||||
debugUI('从收藏库播放', playFrom)
|
console.log('play from', playFrom)
|
||||||
playTheList('favourites', playFrom)
|
playTheList('favourites', playFrom)
|
||||||
}" />
|
}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
|
||||||
import { usePlayState } from '../stores/usePlayState'
|
|
||||||
import { artistsOrganize } from '../utils'
|
import { artistsOrganize } from '../utils'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { Draggable } from 'gsap/Draggable'
|
import { Draggable } from "gsap/Draggable"
|
||||||
import { onMounted, onUnmounted, nextTick } from 'vue'
|
import { onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useTemplateRef } from 'vue'
|
import { useTemplateRef } from 'vue'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { usePreferences } from '../stores/usePreferences'
|
import { usePreferences } from '../stores/usePreferences'
|
||||||
import { useFavourites } from '../stores/useFavourites'
|
import { useFavourites } from '../stores/useFavourites'
|
||||||
import { debugPlayroom } from '../utils/debug'
|
|
||||||
|
|
||||||
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
|
import ScrollingLyrics from '../components/ScrollingLyrics.vue'
|
||||||
|
|
||||||
|
@ -33,7 +31,6 @@ import MuscialNoteSparklingIcon from '../assets/icons/musicalnotesparkling.vue'
|
||||||
import CastEmptyIcon from '../assets/icons/castempty.vue'
|
import CastEmptyIcon from '../assets/icons/castempty.vue'
|
||||||
|
|
||||||
const playQueueStore = usePlayQueueStore()
|
const playQueueStore = usePlayQueueStore()
|
||||||
const playState = usePlayState()
|
|
||||||
const preferences = usePreferences()
|
const preferences = usePreferences()
|
||||||
const favourites = useFavourites()
|
const favourites = useFavourites()
|
||||||
|
|
||||||
|
@ -69,9 +66,9 @@ onMounted(async () => {
|
||||||
onDrag: function () {
|
onDrag: function () {
|
||||||
const thumbPosition = this.x
|
const thumbPosition = this.x
|
||||||
const containerWidth = progressBarContainer.value?.clientWidth || 0
|
const containerWidth = progressBarContainer.value?.clientWidth || 0
|
||||||
// const newTime = (thumbPosition / containerWidth) * playQueueStore.duration
|
const newTime = (thumbPosition / containerWidth) * playQueueStore.duration
|
||||||
// playState.reportPlayProgress
|
playQueueStore.updatedCurrentTime = newTime
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 等待DOM完全渲染后再初始化拖拽
|
// 等待DOM完全渲染后再初始化拖拽
|
||||||
|
@ -93,27 +90,20 @@ onMounted(async () => {
|
||||||
|
|
||||||
function timeFormatter(time: number) {
|
function timeFormatter(time: number) {
|
||||||
const timeInSeconds = Math.floor(time)
|
const timeInSeconds = Math.floor(time)
|
||||||
if (timeInSeconds < 0) {
|
if (timeInSeconds < 0) { return '-:--' }
|
||||||
return '-:--'
|
|
||||||
}
|
|
||||||
const minutes = Math.floor(timeInSeconds / 60)
|
const minutes = Math.floor(timeInSeconds / 60)
|
||||||
const seconds = Math.floor(timeInSeconds % 60)
|
const seconds = Math.floor(timeInSeconds % 60)
|
||||||
if (Number.isNaN(minutes) || Number.isNaN(seconds)) {
|
if (Number.isNaN(minutes) || Number.isNaN(seconds)) { return '-:--' }
|
||||||
return '-:--'
|
|
||||||
}
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听播放进度,更新进度条
|
// 监听播放进度,更新进度条
|
||||||
watch(
|
watch(() => playQueueStore.currentTime, () => {
|
||||||
() => playState.playProgress,
|
thumbUpdate()
|
||||||
() => {
|
})
|
||||||
thumbUpdate()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function thumbUpdate() {
|
function thumbUpdate() {
|
||||||
const progress = playState.playProgressPercent
|
const progress = playQueueStore.currentTime / playQueueStore.duration
|
||||||
const containerWidth = progressBarContainer.value?.clientWidth || 0
|
const containerWidth = progressBarContainer.value?.clientWidth || 0
|
||||||
const thumbWidth = progressBarThumb.value?.clientWidth || 0
|
const thumbWidth = progressBarThumb.value?.clientWidth || 0
|
||||||
const newPosition = (containerWidth - thumbWidth) * progress
|
const newPosition = (containerWidth - thumbWidth) * progress
|
||||||
|
@ -142,14 +132,14 @@ function toggleVolumeControl() {
|
||||||
|
|
||||||
function createVolumeDraggable() {
|
function createVolumeDraggable() {
|
||||||
if (!volumeSliderThumb.value || !volumeSliderContainer.value) {
|
if (!volumeSliderThumb.value || !volumeSliderContainer.value) {
|
||||||
debugPlayroom('音量滑块元素未找到')
|
console.warn('Volume slider elements not found')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保容器有宽度
|
// 确保容器有宽度
|
||||||
const containerWidth = volumeSliderContainer.value.clientWidth
|
const containerWidth = volumeSliderContainer.value.clientWidth
|
||||||
if (containerWidth === 0) {
|
if (containerWidth === 0) {
|
||||||
debugPlayroom('音量滑块容器宽度为0')
|
console.warn('Volume slider container has no width')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,10 +152,7 @@ function createVolumeDraggable() {
|
||||||
const containerWidth = volumeSliderContainer.value?.clientWidth || 0
|
const containerWidth = volumeSliderContainer.value?.clientWidth || 0
|
||||||
const thumbWidth = volumeSliderThumb.value?.clientWidth || 0
|
const thumbWidth = volumeSliderThumb.value?.clientWidth || 0
|
||||||
// 确保音量值在0-1之间
|
// 确保音量值在0-1之间
|
||||||
const newVolume = Math.max(
|
const newVolume = Math.max(0, Math.min(1, thumbPosition / (containerWidth - thumbWidth)))
|
||||||
0,
|
|
||||||
Math.min(1, thumbPosition / (containerWidth - thumbWidth)),
|
|
||||||
)
|
|
||||||
volume.value = newVolume
|
volume.value = newVolume
|
||||||
updateAudioVolume()
|
updateAudioVolume()
|
||||||
// 保存音量到localStorage
|
// 保存音量到localStorage
|
||||||
|
@ -174,10 +161,10 @@ function createVolumeDraggable() {
|
||||||
onDragEnd: () => {
|
onDragEnd: () => {
|
||||||
// 拖拽结束时也保存一次
|
// 拖拽结束时也保存一次
|
||||||
localStorage.setItem('audioVolume', volume.value.toString())
|
localStorage.setItem('audioVolume', volume.value.toString())
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
debugPlayroom('音量滑块拖拽创建成功')
|
console.log('Volume draggable created successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAudioVolume() {
|
function updateAudioVolume() {
|
||||||
|
@ -189,7 +176,7 @@ function updateAudioVolume() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDetector() {
|
function formatDetector() {
|
||||||
const format = playQueueStore.currentTrack.sourceUrl?.split('.').pop()
|
const format = playQueueStore.list[playQueueStore.currentIndex].song.sourceUrl?.split('.').pop()
|
||||||
if (format === 'mp3') { return 'MP3' }
|
if (format === 'mp3') { return 'MP3' }
|
||||||
if (format === 'flac') { return 'FLAC' }
|
if (format === 'flac') { return 'FLAC' }
|
||||||
if (format === 'm4a') { return 'M4A' }
|
if (format === 'm4a') { return 'M4A' }
|
||||||
|
@ -199,46 +186,40 @@ function formatDetector() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function playNext() {
|
function playNext() {
|
||||||
// if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
|
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
|
||||||
// debugPlayroom('到达播放队列末尾,暂停')
|
console.log("at the bottom, pause")
|
||||||
// playQueueStore.currentIndex = 0
|
playQueueStore.currentIndex = 0
|
||||||
// playQueueStore.isPlaying = false
|
playQueueStore.isPlaying = false
|
||||||
// } else {
|
} else {
|
||||||
// playQueueStore.currentIndex++
|
playQueueStore.currentIndex++
|
||||||
// playQueueStore.isPlaying = true
|
playQueueStore.isPlaying = true
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playPrevious() {
|
function playPrevious() {
|
||||||
// if (playQueueStore.currentTime < 5 && playQueueStore.currentIndex > 0) {
|
if (playQueueStore.currentTime < 5 && playQueueStore.currentIndex > 0) {
|
||||||
// playQueueStore.currentIndex--
|
playQueueStore.currentIndex--
|
||||||
// playQueueStore.isPlaying = true
|
playQueueStore.isPlaying = true
|
||||||
// } else {
|
} else {
|
||||||
// playQueueStore.updatedCurrentTime = 0
|
playQueueStore.updatedCurrentTime = 0
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEntranceAnimations() {
|
function setupEntranceAnimations() {
|
||||||
if (controllerRef.value) {
|
if (controllerRef.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(controllerRef.value.children,
|
||||||
controllerRef.value.children,
|
|
||||||
{ opacity: 0, y: 30, scale: 0.95 },
|
{ opacity: 0, y: 30, scale: 0.95 },
|
||||||
{
|
{
|
||||||
opacity: 1,
|
opacity: 1, y: 0, scale: 1,
|
||||||
y: 0,
|
duration: 0.6, ease: "power2.out", stagger: 0.1
|
||||||
scale: 1,
|
}
|
||||||
duration: 0.6,
|
|
||||||
ease: 'power2.out',
|
|
||||||
stagger: 0.1,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyricsSection.value) {
|
if (lyricsSection.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(lyricsSection.value,
|
||||||
lyricsSection.value,
|
|
||||||
{ opacity: 0, x: 50 },
|
{ opacity: 0, x: 50 },
|
||||||
{ opacity: 1, x: 0, duration: 0.8, ease: 'power2.out', delay: 0.3 },
|
{ opacity: 1, x: 0, duration: 0.8, ease: "power2.out", delay: 0.3 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -246,37 +227,28 @@ function setupEntranceAnimations() {
|
||||||
function handlePlayPause() {
|
function handlePlayPause() {
|
||||||
if (playButton.value) {
|
if (playButton.value) {
|
||||||
gsap.to(playButton.value, {
|
gsap.to(playButton.value, {
|
||||||
scale: 0.9,
|
scale: 0.9, duration: 0.1, yoyo: true, repeat: 1,
|
||||||
duration: 0.1,
|
ease: "power2.inOut",
|
||||||
yoyo: true,
|
|
||||||
repeat: 1,
|
|
||||||
ease: 'power2.inOut',
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
playState.togglePlay()
|
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
playState.togglePlay()
|
playQueueStore.isPlaying = !playQueueStore.isPlaying
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShuffle() {
|
function toggleShuffle() {
|
||||||
// playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
|
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
|
||||||
// playQueueStore.shuffleCurrent = false
|
playQueueStore.shuffleCurrent = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRepeat() {
|
function toggleRepeat() {
|
||||||
// switch (playQueueStore.playMode.repeat) {
|
switch (playQueueStore.playMode.repeat) {
|
||||||
// case 'off':
|
case 'off': playQueueStore.playMode.repeat = 'all'; break
|
||||||
// playQueueStore.playMode.repeat = 'all'
|
case 'all': playQueueStore.playMode.repeat = 'single'; break
|
||||||
// break
|
case 'single': playQueueStore.playMode.repeat = 'off'; break
|
||||||
// case 'all':
|
}
|
||||||
// playQueueStore.playMode.repeat = 'single'
|
|
||||||
// break
|
|
||||||
// case 'single':
|
|
||||||
// playQueueStore.playMode.repeat = 'off'
|
|
||||||
// break
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePlayQueueListPresent() {
|
function makePlayQueueListPresent() {
|
||||||
|
@ -287,26 +259,15 @@ function makePlayQueueListPresent() {
|
||||||
|
|
||||||
const tl = gsap.timeline()
|
const tl = gsap.timeline()
|
||||||
tl.to(playQueueDialogContainer.value, {
|
tl.to(playQueueDialogContainer.value, {
|
||||||
backgroundColor: '#17171780',
|
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out'
|
||||||
duration: 0.3,
|
}).to(playQueueDialog.value, {
|
||||||
ease: 'power2.out',
|
x: 0, duration: 0.4, ease: 'power3.out'
|
||||||
}).to(
|
}, '<0.1')
|
||||||
playQueueDialog.value,
|
|
||||||
{
|
|
||||||
x: 0,
|
|
||||||
duration: 0.4,
|
|
||||||
ease: 'power3.out',
|
|
||||||
},
|
|
||||||
'<0.1',
|
|
||||||
)
|
|
||||||
|
|
||||||
if (playQueueDialog.value.children.length > 0) {
|
if (playQueueDialog.value.children.length > 0) {
|
||||||
tl.fromTo(
|
tl.fromTo(playQueueDialog.value.children,
|
||||||
playQueueDialog.value.children,
|
|
||||||
{ opacity: 0, x: -20 },
|
{ opacity: 0, x: -20 },
|
||||||
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 },
|
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 }, '<0.2')
|
||||||
'<0.2',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -324,48 +285,34 @@ function makePlayQueueListDismiss() {
|
||||||
gsap.set(playQueueDialog.value, { x: -384 })
|
gsap.set(playQueueDialog.value, { x: -384 })
|
||||||
}
|
}
|
||||||
if (playQueueDialogContainer.value) {
|
if (playQueueDialogContainer.value) {
|
||||||
gsap.set(playQueueDialogContainer.value, {
|
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' })
|
||||||
backgroundColor: 'transparent',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (playQueueDialog.value.children.length > 0) {
|
if (playQueueDialog.value.children.length > 0) {
|
||||||
tl.to(playQueueDialog.value.children, {
|
tl.to(playQueueDialog.value.children, {
|
||||||
opacity: 0,
|
opacity: 0, x: -20, duration: 0.2, ease: 'power2.in', stagger: 0.03
|
||||||
x: -20,
|
|
||||||
duration: 0.2,
|
|
||||||
ease: 'power2.in',
|
|
||||||
stagger: 0.03,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
tl.to(
|
tl.to(playQueueDialog.value, {
|
||||||
playQueueDialog.value,
|
x: -384, duration: 0.3, ease: 'power2.in'
|
||||||
{
|
}, playQueueDialog.value.children.length > 0 ? '<0.1' : '0')
|
||||||
x: -384,
|
.to(playQueueDialogContainer.value, {
|
||||||
duration: 0.3,
|
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in'
|
||||||
ease: 'power2.in',
|
}, '<')
|
||||||
},
|
|
||||||
playQueueDialog.value.children.length > 0 ? '<0.1' : '0',
|
|
||||||
).to(
|
|
||||||
playQueueDialogContainer.value,
|
|
||||||
{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
duration: 0.2,
|
|
||||||
ease: 'power2.in',
|
|
||||||
},
|
|
||||||
'<',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentTrack() {
|
function getCurrentTrack() {
|
||||||
debugPlayroom('获取当前播放轨道', playQueueStore.queue)
|
if (playQueueStore.list.length === 0) {
|
||||||
if (playQueueStore.queue.length === 0) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return playQueueStore.currentTrack
|
if (playQueueStore.playMode.shuffle) {
|
||||||
|
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]]
|
||||||
|
} else {
|
||||||
|
return playQueueStore.list[playQueueStore.currentIndex]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMoreOptions() {
|
function toggleMoreOptions() {
|
||||||
|
@ -374,23 +321,15 @@ function toggleMoreOptions() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (moreOptionsDialog.value) {
|
if (moreOptionsDialog.value) {
|
||||||
const tl = gsap.timeline()
|
const tl = gsap.timeline()
|
||||||
tl.fromTo(
|
tl.fromTo(moreOptionsDialog.value,
|
||||||
moreOptionsDialog.value,
|
|
||||||
{ opacity: 0, scale: 0.9, y: 10 },
|
{ opacity: 0, scale: 0.9, y: 10 },
|
||||||
{ opacity: 1, scale: 1, y: 0, duration: 0.2, ease: 'power2.out' },
|
{ opacity: 1, scale: 1, y: 0, duration: 0.2, ease: "power2.out" }
|
||||||
)
|
)
|
||||||
if (moreOptionsDialog.value.children[0]?.children) {
|
if (moreOptionsDialog.value.children[0]?.children) {
|
||||||
tl.fromTo(
|
tl.fromTo(moreOptionsDialog.value.children[0].children,
|
||||||
moreOptionsDialog.value.children[0].children,
|
|
||||||
{ opacity: 0, x: -10 },
|
{ opacity: 0, x: -10 },
|
||||||
{
|
{ opacity: 1, x: 0, duration: 0.15, ease: "power2.out", stagger: 0.05 },
|
||||||
opacity: 1,
|
"<0.1"
|
||||||
x: 0,
|
|
||||||
duration: 0.15,
|
|
||||||
ease: 'power2.out',
|
|
||||||
stagger: 0.05,
|
|
||||||
},
|
|
||||||
'<0.1',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -400,21 +339,16 @@ function toggleMoreOptions() {
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
showMoreOptions.value = false
|
showMoreOptions.value = false
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
if (moreOptionsDialog.value.children[0]?.children) {
|
if (moreOptionsDialog.value.children[0]?.children) {
|
||||||
tl.to(moreOptionsDialog.value.children[0].children, {
|
tl.to(moreOptionsDialog.value.children[0].children,
|
||||||
opacity: 0,
|
{ opacity: 0, x: -10, duration: 0.1, ease: "power2.in", stagger: 0.02 }
|
||||||
x: -10,
|
)
|
||||||
duration: 0.1,
|
|
||||||
ease: 'power2.in',
|
|
||||||
stagger: 0.02,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
tl.to(
|
tl.to(moreOptionsDialog.value,
|
||||||
moreOptionsDialog.value,
|
{ opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: "power2.in" },
|
||||||
{ opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: 'power2.in' },
|
moreOptionsDialog.value.children[0]?.children ? "<0.05" : "0"
|
||||||
moreOptionsDialog.value.children[0]?.children ? '<0.05' : '0',
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
showMoreOptions.value = false
|
showMoreOptions.value = false
|
||||||
|
@ -422,90 +356,71 @@ function toggleMoreOptions() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newValue, oldValue) => {
|
||||||
() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl],
|
if (!getCurrentTrack()) { return }
|
||||||
(newValue, oldValue) => {
|
|
||||||
if (!getCurrentTrack()) {
|
const [showLyrics, hasLyricUrl] = newValue
|
||||||
return
|
const [prevShowLyrics, _prevHasLyricUrl] = oldValue || [false, null]
|
||||||
|
|
||||||
|
// Show lyrics when both conditions are met
|
||||||
|
if (showLyrics && hasLyricUrl) {
|
||||||
|
presentLyrics.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
const tl = gsap.timeline()
|
||||||
|
tl.from(controllerRef.value, {
|
||||||
|
marginRight: '-40rem',
|
||||||
|
}).fromTo(lyricsSection.value,
|
||||||
|
{ opacity: 0, x: 50, scale: 0.95 },
|
||||||
|
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" },
|
||||||
|
"-=0.3"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Hide lyrics with different animations based on reason
|
||||||
|
else if (presentLyrics.value) {
|
||||||
|
let animationConfig
|
||||||
|
|
||||||
|
// If lyrics were toggled off
|
||||||
|
if (prevShowLyrics && !showLyrics) {
|
||||||
|
animationConfig = {
|
||||||
|
opacity: 0, x: -50, scale: 0.95,
|
||||||
|
duration: 0.3, ease: "power2.in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no lyrics available (song changed)
|
||||||
|
else if (!hasLyricUrl) {
|
||||||
|
animationConfig = {
|
||||||
|
opacity: 0, y: -20, scale: 0.98,
|
||||||
|
duration: 0.3, ease: "power1.in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default animation
|
||||||
|
else {
|
||||||
|
animationConfig = {
|
||||||
|
opacity: 0, x: -50,
|
||||||
|
duration: 0.3, ease: "power2.in"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [showLyrics, hasLyricUrl] = newValue
|
const tl = gsap.timeline({
|
||||||
const [prevShowLyrics, _prevHasLyricUrl] = oldValue || [false, null]
|
onComplete: () => {
|
||||||
|
presentLyrics.value = false
|
||||||
// Show lyrics when both conditions are met
|
|
||||||
if (showLyrics && hasLyricUrl) {
|
|
||||||
presentLyrics.value = true
|
|
||||||
nextTick(() => {
|
|
||||||
const tl = gsap.timeline()
|
|
||||||
tl.from(controllerRef.value, {
|
|
||||||
marginRight: '-40rem',
|
|
||||||
}).fromTo(
|
|
||||||
lyricsSection.value,
|
|
||||||
{ opacity: 0, x: 50, scale: 0.95 },
|
|
||||||
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: 'power2.out' },
|
|
||||||
'-=0.3',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Hide lyrics with different animations based on reason
|
|
||||||
else if (presentLyrics.value) {
|
|
||||||
let animationConfig
|
|
||||||
|
|
||||||
// If lyrics were toggled off
|
|
||||||
if (prevShowLyrics && !showLyrics) {
|
|
||||||
animationConfig = {
|
|
||||||
opacity: 0,
|
|
||||||
x: -50,
|
|
||||||
scale: 0.95,
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'power2.in',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no lyrics available (song changed)
|
|
||||||
else if (!hasLyricUrl) {
|
|
||||||
animationConfig = {
|
|
||||||
opacity: 0,
|
|
||||||
y: -20,
|
|
||||||
scale: 0.98,
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'power1.in',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Default animation
|
|
||||||
else {
|
|
||||||
animationConfig = {
|
|
||||||
opacity: 0,
|
|
||||||
x: -50,
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'power2.in',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
tl.to(controllerRef.value, {
|
||||||
onComplete: () => {
|
marginLeft: '44rem',
|
||||||
presentLyrics.value = false
|
duration: 0.3, ease: "power2.out"
|
||||||
},
|
})
|
||||||
|
.to(lyricsSection.value, animationConfig, '<')
|
||||||
|
.set(lyricsSection.value, {
|
||||||
|
opacity: 1, x: 0, y: 0, scale: 1 // Reset for next time
|
||||||
})
|
})
|
||||||
|
.set(controllerRef.value, {
|
||||||
tl.to(controllerRef.value, {
|
marginLeft: '0rem' // Reset for next time
|
||||||
marginLeft: '44rem',
|
|
||||||
duration: 0.3,
|
|
||||||
ease: 'power2.out',
|
|
||||||
})
|
})
|
||||||
.to(lyricsSection.value, animationConfig, '<')
|
}
|
||||||
.set(lyricsSection.value, {
|
}, { immediate: true })
|
||||||
opacity: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
scale: 1, // Reset for next time
|
|
||||||
})
|
|
||||||
.set(controllerRef.value, {
|
|
||||||
marginLeft: '0rem', // Reset for next time
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 页面焦点处理函数变量声明
|
// 页面焦点处理函数变量声明
|
||||||
let handleVisibilityChange: (() => void) | null = null
|
let handleVisibilityChange: (() => void) | null = null
|
||||||
|
@ -526,10 +441,10 @@ function setupPageFocusHandlers() {
|
||||||
handleVisibilityChange = () => {
|
handleVisibilityChange = () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
// 页面失去焦点时,暂停所有动画
|
// 页面失去焦点时,暂停所有动画
|
||||||
debugPlayroom('页面失去焦点,暂停动画')
|
console.log('[Playroom] 页面失去焦点,暂停动画')
|
||||||
} else {
|
} else {
|
||||||
// 页面重新获得焦点时,重新同步状态
|
// 页面重新获得焦点时,重新同步状态
|
||||||
debugPlayroom('页面重新获得焦点,同步状态')
|
console.log('[Playroom] 页面重新获得焦点,同步状态')
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resyncLyricsState()
|
resyncLyricsState()
|
||||||
})
|
})
|
||||||
|
@ -537,7 +452,7 @@ function setupPageFocusHandlers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePageFocus = () => {
|
handlePageFocus = () => {
|
||||||
debugPlayroom('窗口获得焦点,同步状态')
|
console.log('[Playroom] 窗口获得焦点,同步状态')
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
resyncLyricsState()
|
resyncLyricsState()
|
||||||
})
|
})
|
||||||
|
@ -551,41 +466,36 @@ function setupPageFocusHandlers() {
|
||||||
// 重新同步歌词状态
|
// 重新同步歌词状态
|
||||||
function resyncLyricsState() {
|
function resyncLyricsState() {
|
||||||
const currentTrack = getCurrentTrack()
|
const currentTrack = getCurrentTrack()
|
||||||
if (!currentTrack) {
|
if (!currentTrack) { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPlayroom('重新同步歌词状态')
|
console.log('[Playroom] 重新同步歌词状态')
|
||||||
|
|
||||||
// 重置动画状态
|
// 重置动画状态
|
||||||
if (controllerRef.value) {
|
if (controllerRef.value) {
|
||||||
gsap.set(controllerRef.value, {
|
gsap.set(controllerRef.value, {
|
||||||
marginLeft: '0rem',
|
marginLeft: '0rem',
|
||||||
marginRight: '0rem',
|
marginRight: '0rem'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lyricsSection.value) {
|
if (lyricsSection.value) {
|
||||||
gsap.set(lyricsSection.value, {
|
gsap.set(lyricsSection.value, {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
scale: 1,
|
scale: 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查当前歌词显示状态应该是什么
|
// 检查当前歌词显示状态应该是什么
|
||||||
const shouldShowLyrics =
|
const shouldShowLyrics = preferences.presentLyrics && currentTrack.song.lyricUrl ? true : false
|
||||||
preferences.presentLyrics && currentTrack.song.lyricUrl ? true : false
|
|
||||||
|
|
||||||
if (shouldShowLyrics !== presentLyrics.value) {
|
if (shouldShowLyrics !== presentLyrics.value) {
|
||||||
debugPlayroom(
|
console.log(`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`)
|
||||||
`歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 直接设置状态,不触发动画
|
// 直接设置状态,不触发动画
|
||||||
presentLyrics.value = shouldShowLyrics
|
presentLyrics.value = shouldShowLyrics
|
||||||
|
|
||||||
// 如果需要显示歌词,重新执行显示动画
|
// 如果需要显示歌词,重新执行显示动画
|
||||||
if (shouldShowLyrics) {
|
if (shouldShowLyrics) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
@ -593,12 +503,11 @@ function resyncLyricsState() {
|
||||||
tl.from(controllerRef.value, {
|
tl.from(controllerRef.value, {
|
||||||
marginRight: '-40rem',
|
marginRight: '-40rem',
|
||||||
duration: 0.4,
|
duration: 0.4,
|
||||||
ease: 'power2.out',
|
ease: "power2.out"
|
||||||
}).fromTo(
|
}).fromTo(lyricsSection.value,
|
||||||
lyricsSection.value,
|
|
||||||
{ opacity: 0, x: 50, scale: 0.95 },
|
{ opacity: 0, x: 50, scale: 0.95 },
|
||||||
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: 'power2.out' },
|
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" },
|
||||||
'-=0.2',
|
"-=0.2"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -606,29 +515,21 @@ function resyncLyricsState() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New: Watch for track changes and animate
|
// New: Watch for track changes and animate
|
||||||
watch(
|
watch(() => playQueueStore.currentIndex, () => {
|
||||||
() => playQueueStore.currentIndex,
|
if (albumCover.value) {
|
||||||
() => {
|
gsap.to(albumCover.value, {
|
||||||
if (albumCover.value) {
|
scale: 0.95, opacity: 0.7, duration: 0.2,
|
||||||
gsap.to(albumCover.value, {
|
ease: "power2.inOut", yoyo: true, repeat: 1
|
||||||
scale: 0.95,
|
})
|
||||||
opacity: 0.7,
|
}
|
||||||
duration: 0.2,
|
|
||||||
ease: 'power2.inOut',
|
|
||||||
yoyo: true,
|
|
||||||
repeat: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (songInfo.value) {
|
if (songInfo.value) {
|
||||||
gsap.fromTo(
|
gsap.fromTo(songInfo.value,
|
||||||
songInfo.value,
|
{ opacity: 0, y: 10 },
|
||||||
{ opacity: 0, y: 10 },
|
{ opacity: 1, y: 0, duration: 0.4, ease: "power2.out", delay: 0.3 }
|
||||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', delay: 0.3 },
|
)
|
||||||
)
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -650,7 +551,7 @@ watch(
|
||||||
<div ref="albumCover" class="relative">
|
<div ref="albumCover" class="relative">
|
||||||
<img :src="getCurrentTrack()?.album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96
|
<img :src="getCurrentTrack()?.album?.coverUrl" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96
|
||||||
transition-transform duration-300
|
transition-transform duration-300
|
||||||
" :class="playState.actualPlaying ? 'scale-100' : 'scale-85'" />
|
" :class="playQueueStore.isPlaying ? 'scale-100' : 'scale-85'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Song info with enhanced styling -->
|
<!-- Song info with enhanced styling -->
|
||||||
|
@ -706,9 +607,9 @@ watch(
|
||||||
<!-- ...existing time display code... -->
|
<!-- ...existing time display code... -->
|
||||||
<div class="font-medium flex-1 text-left text-xs relative">
|
<div class="font-medium flex-1 text-left text-xs relative">
|
||||||
<span
|
<span
|
||||||
class="text-black blur-lg absolute top-0 text-xs">{{ timeFormatter(Math.floor(playState.playProgress)) }}</span>
|
class="text-black blur-lg absolute top-0 text-xs">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||||
<span
|
<span
|
||||||
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playState.playProgress)) }}</span>
|
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-center relative flex-1">
|
<div class="text-xs text-center relative flex-1">
|
||||||
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
|
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
|
||||||
|
@ -720,8 +621,8 @@ watch(
|
||||||
class="text-white/90 text-xs font-medium text-right relative transition-colors duration-200 hover:text-white"
|
class="text-white/90 text-xs font-medium text-right relative transition-colors duration-200 hover:text-white"
|
||||||
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
|
||||||
<span
|
<span
|
||||||
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playState.trackDuration) - Math.floor(playState.playProgress) : playState.trackDuration)}` }}</span>
|
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||||
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playState.trackDuration) - Math.floor(playState.playProgress) : playState.trackDuration)}` }}</span>
|
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -783,8 +684,8 @@ watch(
|
||||||
class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200"
|
class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200"
|
||||||
@click="handlePlayPause" ref="playButton">
|
@click="handlePlayPause" ref="playButton">
|
||||||
<!-- ...existing play/pause icon code... -->
|
<!-- ...existing play/pause icon code... -->
|
||||||
<div v-if="playState.isPlaying">
|
<div v-if="playQueueStore.isPlaying">
|
||||||
<div v-if="false" class="w-6 h-6 relative"> <!-- 原本是检测缓冲状态的 -->
|
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
|
||||||
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
<span class="text-black/80 blur-lg absolute top-0 left-0">
|
||||||
<LoadingIndicator :size="6" />
|
<LoadingIndicator :size="6" />
|
||||||
</span>
|
</span>
|
||||||
|
@ -925,29 +826,29 @@ watch(
|
||||||
<div class="flex gap-2 mx-8 mb-4">
|
<div class="flex gap-2 mx-8 mb-4">
|
||||||
<button
|
<button
|
||||||
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
||||||
:class="playQueueStore.isShuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
|
:class="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
|
||||||
@click="toggleShuffle">
|
@click="toggleShuffle">
|
||||||
<ShuffleIcon :size="4" />
|
<ShuffleIcon :size="4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
class="flex-1 h-9 border border-[#ffffff39] rounded-full text-center backdrop-blur-3xl flex justify-center items-center transition-all duration-200 hover:scale-105"
|
||||||
:class="playQueueStore.loopMode === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
|
:class="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
|
||||||
@click="toggleRepeat">
|
@click="toggleRepeat">
|
||||||
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.loopMode !== 'single'" />
|
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
|
||||||
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
|
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="border-[#ffffff39]" />
|
<hr class="border-[#ffffff39]" />
|
||||||
|
|
||||||
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-if="playQueueStore.isShuffle">
|
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-if="playQueueStore.playMode.shuffle">
|
||||||
<!--PlayQueueItem v-for="(oriIndex, shuffledIndex) in playQueueStore.isShuffle"
|
<PlayQueueItem v-for="(oriIndex, shuffledIndex) in playQueueStore.shuffleList"
|
||||||
:queueItem="playQueueStore.queue[oriIndex]" :isCurrent="playQueueStore.currentIndex === shuffledIndex"
|
:queueItem="playQueueStore.list[oriIndex]" :isCurrent="playQueueStore.currentIndex === shuffledIndex"
|
||||||
:key="playQueueStore.queue[oriIndex].song.cid" :index="shuffledIndex" /-->
|
:key="playQueueStore.list[oriIndex].song.cid" :index="shuffledIndex" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-else>
|
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-else>
|
||||||
<PlayQueueItem :queueItem="track" :isCurrent="playQueueStore.currentIndex === index"
|
<PlayQueueItem :queueItem="track" :isCurrent="playQueueStore.currentIndex === index"
|
||||||
v-for="(track, index) in playQueueStore.queue" :index="index" :key="track.song.cid" />
|
v-for="(track, index) in playQueueStore.list" :index="index" :key="track.song.cid" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
@ -998,4 +899,4 @@ watch(
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
|
@ -1,6 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from "pinia"
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from "vue"
|
||||||
import { debugStore } from '../utils/debug'
|
|
||||||
|
|
||||||
// 声明全局类型
|
// 声明全局类型
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -22,11 +21,7 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
const detectAvailableAPIs = () => {
|
const detectAvailableAPIs = () => {
|
||||||
// 检查原生 chrome API
|
// 检查原生 chrome API
|
||||||
try {
|
try {
|
||||||
if (
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
|
||||||
typeof chrome !== 'undefined' &&
|
|
||||||
chrome.storage &&
|
|
||||||
chrome.storage.local
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -36,11 +31,7 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
|
|
||||||
// 检查 window.chrome
|
// 检查 window.chrome
|
||||||
try {
|
try {
|
||||||
if (
|
if (window.chrome && window.chrome.storage && window.chrome.storage.local) {
|
||||||
window.chrome &&
|
|
||||||
window.chrome.storage &&
|
|
||||||
window.chrome.storage.local
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -140,62 +131,50 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
const normalizeFavourites = (data: any[]): QueueItem[] => {
|
const normalizeFavourites = (data: any[]): QueueItem[] => {
|
||||||
if (!Array.isArray(data)) return []
|
if (!Array.isArray(data)) return []
|
||||||
|
|
||||||
return data
|
return data.map(item => {
|
||||||
.map((item) => {
|
if (!item || !item.song) return null
|
||||||
if (!item || !item.song) return null
|
|
||||||
|
|
||||||
// 规范化 Song 对象
|
// 规范化 Song 对象
|
||||||
const song: Song = {
|
const song: Song = {
|
||||||
cid: item.song.cid || '',
|
cid: item.song.cid || '',
|
||||||
name: item.song.name || '',
|
name: item.song.name || '',
|
||||||
albumCid: item.song.albumCid,
|
albumCid: item.song.albumCid,
|
||||||
sourceUrl: item.song.sourceUrl,
|
sourceUrl: item.song.sourceUrl,
|
||||||
lyricUrl: item.song.lyricUrl,
|
lyricUrl: item.song.lyricUrl,
|
||||||
mvUrl: item.song.mvUrl,
|
mvUrl: item.song.mvUrl,
|
||||||
mvCoverUrl: item.song.mvCoverUrl,
|
mvCoverUrl: item.song.mvCoverUrl,
|
||||||
// 确保 artistes 和 artists 是数组
|
// 确保 artistes 和 artists 是数组
|
||||||
artistes: Array.isArray(item.song.artistes)
|
artistes: Array.isArray(item.song.artistes) ? item.song.artistes :
|
||||||
? item.song.artistes
|
typeof item.song.artistes === 'object' ? Object.values(item.song.artistes) :
|
||||||
: typeof item.song.artistes === 'object'
|
[],
|
||||||
? Object.values(item.song.artistes)
|
artists: Array.isArray(item.song.artists) ? item.song.artists :
|
||||||
: [],
|
typeof item.song.artists === 'object' ? Object.values(item.song.artists) :
|
||||||
artists: Array.isArray(item.song.artists)
|
[]
|
||||||
? item.song.artists
|
}
|
||||||
: typeof item.song.artists === 'object'
|
|
||||||
? Object.values(item.song.artists)
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 规范化 Album 对象(如果存在)
|
// 规范化 Album 对象(如果存在)
|
||||||
const album = item.album
|
const album = item.album ? {
|
||||||
? {
|
cid: item.album.cid || '',
|
||||||
cid: item.album.cid || '',
|
name: item.album.name || '',
|
||||||
name: item.album.name || '',
|
intro: item.album.intro,
|
||||||
intro: item.album.intro,
|
belong: item.album.belong,
|
||||||
belong: item.album.belong,
|
coverUrl: item.album.coverUrl || '',
|
||||||
coverUrl: item.album.coverUrl || '',
|
coverDeUrl: item.album.coverDeUrl,
|
||||||
coverDeUrl: item.album.coverDeUrl,
|
artistes: Array.isArray(item.album.artistes) ? item.album.artistes :
|
||||||
artistes: Array.isArray(item.album.artistes)
|
typeof item.album.artistes === 'object' ? Object.values(item.album.artistes) :
|
||||||
? item.album.artistes
|
[],
|
||||||
: typeof item.album.artistes === 'object'
|
songs: item.album.songs
|
||||||
? Object.values(item.album.artistes)
|
} : undefined
|
||||||
: [],
|
|
||||||
songs: item.album.songs,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return { song, album }
|
return { song, album }
|
||||||
})
|
}).filter(Boolean) as QueueItem[]
|
||||||
.filter(Boolean) as QueueItem[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取收藏列表
|
// 获取收藏列表
|
||||||
const getFavourites = async () => {
|
const getFavourites = async () => {
|
||||||
const result = await getStoredValue('favourites', defaultFavourites)
|
const result = await getStoredValue('favourites', defaultFavourites)
|
||||||
// 确保返回的是数组并进行数据规范化
|
// 确保返回的是数组并进行数据规范化
|
||||||
const normalizedResult = Array.isArray(result)
|
const normalizedResult = Array.isArray(result) ? normalizeFavourites(result) : defaultFavourites
|
||||||
? normalizeFavourites(result)
|
|
||||||
: defaultFavourites
|
|
||||||
return normalizedResult
|
return normalizedResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -208,7 +187,7 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
|
|
||||||
// 检查歌曲是否已收藏
|
// 检查歌曲是否已收藏
|
||||||
const isFavourite = (songCid: string): boolean => {
|
const isFavourite = (songCid: string): boolean => {
|
||||||
return favourites.value.some((item) => item.song.cid === songCid)
|
return favourites.value.some(item => item.song.cid === songCid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加到收藏
|
// 添加到收藏
|
||||||
|
@ -229,9 +208,7 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
|
|
||||||
// 从收藏中移除
|
// 从收藏中移除
|
||||||
const removeFromFavourites = async (songCid: string) => {
|
const removeFromFavourites = async (songCid: string) => {
|
||||||
const index = favourites.value.findIndex(
|
const index = favourites.value.findIndex(item => item.song.cid === songCid)
|
||||||
(item) => item.song.cid === songCid,
|
|
||||||
)
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const removedItem = favourites.value.splice(index, 1)[0]
|
const removedItem = favourites.value.splice(index, 1)[0]
|
||||||
if (isLoaded.value) {
|
if (isLoaded.value) {
|
||||||
|
@ -288,44 +265,35 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
|
|
||||||
// 监听变化并保存(防抖处理)
|
// 监听变化并保存(防抖处理)
|
||||||
let saveTimeout: NodeJS.Timeout | null = null
|
let saveTimeout: NodeJS.Timeout | null = null
|
||||||
watch(
|
watch(favourites, async () => {
|
||||||
favourites,
|
if (isLoaded.value) {
|
||||||
async () => {
|
// 清除之前的定时器
|
||||||
if (isLoaded.value) {
|
if (saveTimeout) {
|
||||||
// 清除之前的定时器
|
clearTimeout(saveTimeout)
|
||||||
if (saveTimeout) {
|
|
||||||
clearTimeout(saveTimeout)
|
|
||||||
}
|
|
||||||
// 设置新的定时器,防抖保存
|
|
||||||
saveTimeout = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await saveFavourites()
|
|
||||||
} catch (error) {
|
|
||||||
// Silent fail
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
},
|
// 设置新的定时器,防抖保存
|
||||||
{ deep: true },
|
saveTimeout = setTimeout(async () => {
|
||||||
)
|
try {
|
||||||
|
await saveFavourites()
|
||||||
|
} catch (error) {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// 更新收藏列表中的歌曲信息
|
// 更新收藏列表中的歌曲信息
|
||||||
const updateSongInFavourites = async (songCid: string, updatedSong: Song) => {
|
const updateSongInFavourites = async (songCid: string, updatedSong: Song) => {
|
||||||
const index = favourites.value.findIndex(
|
const index = favourites.value.findIndex(item => item.song.cid === songCid)
|
||||||
(item) => item.song.cid === songCid,
|
|
||||||
)
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
// 更新歌曲信息,保持其他属性不变
|
// 更新歌曲信息,保持其他属性不变
|
||||||
favourites.value[index].song = {
|
favourites.value[index].song = { ...favourites.value[index].song, ...updatedSong }
|
||||||
...favourites.value[index].song,
|
|
||||||
...updatedSong,
|
|
||||||
}
|
|
||||||
if (isLoaded.value) {
|
if (isLoaded.value) {
|
||||||
try {
|
try {
|
||||||
await saveFavourites()
|
await saveFavourites()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 保存失败时可以考虑回滚或错误处理
|
// 保存失败时可以考虑回滚或错误处理
|
||||||
debugStore('更新歌曲信息保存失败', error)
|
console.error('Failed to save updated song:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -349,6 +317,7 @@ export const useFavourites = defineStore('favourites', () => {
|
||||||
clearFavourites,
|
clearFavourites,
|
||||||
getStoredValue,
|
getStoredValue,
|
||||||
setStoredValue,
|
setStoredValue,
|
||||||
updateSongInFavourites,
|
updateSongInFavourites
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,201 +1,217 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { debugStore } from '../utils/debug'
|
import { checkAndRefreshSongResource } from '../utils'
|
||||||
import apis from '../apis'
|
|
||||||
|
|
||||||
export const usePlayQueueStore = defineStore('queue', () => {
|
export const usePlayQueueStore = defineStore('queue', () => {
|
||||||
// 内部状态
|
const list = ref<QueueItem[]>([])
|
||||||
const queue = ref<QueueItem[]>([])
|
const currentIndex = ref<number>(0)
|
||||||
const isShuffle = ref(false)
|
const isPlaying = ref<boolean>(false)
|
||||||
const loopingMode = ref<'single' | 'all' | 'off'>('off')
|
const queueReplaceLock = ref<boolean>(false)
|
||||||
const queueReplaceLock = ref(false)
|
const isBuffering = ref<boolean>(false)
|
||||||
const currentPlaying = ref(0) // 当前播放指针,指针在 queueOrder 中寻址(无论是否开启了随机播放)
|
const currentTime = ref<number>(0)
|
||||||
const queueOrder = ref<number[]>([]) // 播放队列顺序
|
const duration = ref<number>(0)
|
||||||
|
const updatedCurrentTime = ref<number | null>(null)
|
||||||
// 暴露给外部的响应式只读引用
|
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
|
||||||
const queueState = computed(() =>
|
const shuffleList = ref<number[]>([])
|
||||||
// 按 queueOrder 的顺序排序输出队列
|
const playMode = ref<{
|
||||||
queueOrder.value
|
shuffle: boolean
|
||||||
.map((index) => queue.value[index])
|
repeat: 'off' | 'single' | 'all'
|
||||||
.filter(Boolean),
|
}>({
|
||||||
)
|
shuffle: false,
|
||||||
const shuffleState = computed(() => isShuffle.value)
|
repeat: 'off',
|
||||||
const loopModeState = computed(() => loopingMode.value)
|
|
||||||
|
|
||||||
// 获取当前播放项
|
|
||||||
const currentTrack = computed(() => {
|
|
||||||
const actualIndex = queueOrder.value[currentPlaying.value]
|
|
||||||
return queue.value[actualIndex] || null
|
|
||||||
})
|
})
|
||||||
|
const shuffleCurrent = ref<boolean | undefined>(undefined)
|
||||||
|
|
||||||
// 获取上一曲目
|
// 预加载相关状态
|
||||||
const previousTrack = computed(() => {
|
const preloadedAudio = ref<Map<string, HTMLAudioElement>>(new Map())
|
||||||
const actualIndex = queueOrder.value[currentPlaying.value - 1]
|
const isPreloading = ref<boolean>(false)
|
||||||
return queue.value[actualIndex] || null
|
const preloadProgress = ref<number>(0)
|
||||||
})
|
|
||||||
|
|
||||||
// 获取下一曲目
|
// 获取下一首歌的索引
|
||||||
const nextTrack = computed(() => {
|
const getNextIndex = computed(() => {
|
||||||
const actualIndex = queueOrder.value[currentPlaying.value + 1]
|
if (list.value.length === 0) return -1
|
||||||
return queue.value[actualIndex] || null
|
|
||||||
})
|
|
||||||
|
|
||||||
/************
|
if (playMode.value.repeat === 'single') {
|
||||||
* 播放队列相关
|
return currentIndex.value
|
||||||
***********/
|
|
||||||
// 使用新队列替换老队列
|
|
||||||
// 队列替换锁开启时启用确认,确认后重置该锁
|
|
||||||
async function replaceQueue(newQueue: {
|
|
||||||
song: Song,
|
|
||||||
album?: Album
|
|
||||||
}[]) {
|
|
||||||
if (queueReplaceLock.value) {
|
|
||||||
if (
|
|
||||||
!confirm(
|
|
||||||
'当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 重置队列替换锁
|
|
||||||
queueReplaceLock.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以空队列向外部监听器回报队列已被修改
|
if (playMode.value.shuffle && shuffleList.value.length > 0) {
|
||||||
queue.value = []
|
// 当前在 shuffleList 中的位置
|
||||||
queueOrder.value = []
|
const currentShuffleIndex = currentIndex.value
|
||||||
currentPlaying.value = 0
|
if (currentShuffleIndex < shuffleList.value.length - 1) {
|
||||||
|
// 返回下一个位置对应的原始 list 索引
|
||||||
// 获取最新资源地址
|
return shuffleList.value[currentShuffleIndex + 1]
|
||||||
let newQueueWithUrl: QueueItem[] = []
|
} else if (playMode.value.repeat === 'all') {
|
||||||
|
// 返回第一个位置对应的原始 list 索引
|
||||||
for (const track of newQueue) {
|
return shuffleList.value[0]
|
||||||
const res = await apis.getSong(track.song.cid)
|
|
||||||
newQueueWithUrl[newQueueWithUrl.length] = {
|
|
||||||
song: track.song,
|
|
||||||
album: track.album,
|
|
||||||
sourceUrl: res.sourceUrl ?? "",
|
|
||||||
lyricUrl: res.lyricUrl
|
|
||||||
}
|
}
|
||||||
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
debugStore(newQueueWithUrl)
|
if (currentIndex.value < list.value.length - 1) {
|
||||||
|
return currentIndex.value + 1
|
||||||
// 将新队列替换已有队列
|
} else if (playMode.value.repeat === 'all') {
|
||||||
queue.value = newQueueWithUrl
|
return 0
|
||||||
|
|
||||||
// 正式初始化播放顺序
|
|
||||||
queueOrder.value = Array.from({ length: newQueue.length }, (_, i) => i)
|
|
||||||
|
|
||||||
// 关闭随机播放和循环(外部可在此方法执行完毕后再更新播放模式)
|
|
||||||
isShuffle.value = false
|
|
||||||
loopingMode.value = 'off'
|
|
||||||
}
|
|
||||||
|
|
||||||
/***********
|
|
||||||
* 播放控制相关
|
|
||||||
**********/
|
|
||||||
|
|
||||||
// 跳转至队列的某首歌曲
|
|
||||||
const toggleQueuePlay = (turnTo: number) => {
|
|
||||||
if (turnTo < 0 || turnTo >= queue.value.length) return
|
|
||||||
currentPlaying.value = turnTo
|
|
||||||
}
|
|
||||||
|
|
||||||
// 继续播放接下来的曲目
|
|
||||||
// 通常为当前曲目播放完毕,需要通过循环模式判断应该重置进度或队列指针 +1
|
|
||||||
const continueToNext = () => {
|
|
||||||
debugStore(loopingMode.value)
|
|
||||||
if (loopingMode.value !== 'single') {
|
|
||||||
currentPlaying.value = currentPlaying.value + 1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/************
|
return -1
|
||||||
* 播放模式相关
|
})
|
||||||
**********/
|
|
||||||
// 切换随机播放模式
|
|
||||||
const toggleShuffle = (turnTo?: boolean) => {
|
|
||||||
// 未指定随机状态时自动开关
|
|
||||||
const newShuffleState = turnTo ?? !isShuffle.value
|
|
||||||
|
|
||||||
if (newShuffleState === isShuffle.value) return // 状态未改变
|
// 预加载下一首歌
|
||||||
|
const preloadNext = async () => {
|
||||||
|
const nextIndex = getNextIndex.value
|
||||||
|
if (nextIndex === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: 进行洗牌(以下代码是 AI 写的,需要人工复查)
|
// 获取下一首歌曲对象
|
||||||
/* if (newShuffleState) {
|
// nextIndex 已经是原始 list 中的索引
|
||||||
// 开启随机播放:保存当前顺序并打乱
|
const nextSong = list.value[nextIndex]
|
||||||
const originalOrder = [...queueOrder.value]
|
|
||||||
const shuffled = [...queueOrder.value]
|
if (!nextSong || !nextSong.song) {
|
||||||
|
return
|
||||||
// Fisher-Yates 洗牌算法
|
}
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const songId = nextSong.song.cid
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
||||||
}
|
// 如果已经预加载过,跳过
|
||||||
|
if (preloadedAudio.value.has(songId)) {
|
||||||
// 确保当前播放的歌曲位置不变(可选)
|
return
|
||||||
const currentSongIndex = queueOrder.value[currentPlaying.value]
|
}
|
||||||
const newCurrentPos = shuffled.indexOf(currentSongIndex)
|
|
||||||
if (newCurrentPos !== -1 && newCurrentPos !== currentPlaying.value) {
|
// 检查是否有有效的音频源
|
||||||
[shuffled[currentPlaying.value], shuffled[newCurrentPos]] =
|
if (!nextSong.song.sourceUrl) {
|
||||||
[shuffled[newCurrentPos], shuffled[currentPlaying.value]]
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存原始顺序以便恢复
|
try {
|
||||||
queue.value.forEach((_, index) => {
|
isPreloading.value = true
|
||||||
queue.value[index]._originalOrderIndex = originalOrder.indexOf(index)
|
preloadProgress.value = 0
|
||||||
|
|
||||||
|
// 在预加载前检查和刷新资源
|
||||||
|
console.log('[Store] 预加载前检查资源:', nextSong.song.name)
|
||||||
|
const updatedSong = await checkAndRefreshSongResource(
|
||||||
|
nextSong.song,
|
||||||
|
(updated) => {
|
||||||
|
// 更新播放队列中的歌曲信息
|
||||||
|
// nextIndex 已经是原始 list 中的索引
|
||||||
|
if (list.value[nextIndex]) {
|
||||||
|
list.value[nextIndex].song = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果歌曲在收藏夹中,也更新收藏夹
|
||||||
|
// 注意:这里不直接导入 favourites store 以避免循环依赖
|
||||||
|
// 改为触发一个事件或者在调用方处理
|
||||||
|
console.log('[Store] 预加载时需要更新收藏夹:', updated.name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const audio = new Audio()
|
||||||
|
audio.preload = 'auto'
|
||||||
|
audio.crossOrigin = 'anonymous'
|
||||||
|
|
||||||
|
// 监听加载进度
|
||||||
|
audio.addEventListener('progress', () => {
|
||||||
|
if (audio.buffered.length > 0) {
|
||||||
|
const buffered = audio.buffered.end(0)
|
||||||
|
const total = audio.duration || 1
|
||||||
|
preloadProgress.value = (buffered / total) * 100
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
queueOrder.value = shuffled
|
|
||||||
} else {
|
|
||||||
// 关闭随机播放:恢复原始顺序
|
|
||||||
const restoredOrder = Array.from({ length: queue.value.length }, (_, i) => i)
|
|
||||||
|
|
||||||
// 找到当前播放歌曲在原始顺序中的位置
|
|
||||||
const currentSongIndex = queueOrder.value[currentPlaying.value]
|
|
||||||
const newCurrentPos = restoredOrder.indexOf(currentSongIndex)
|
|
||||||
|
|
||||||
queueOrder.value = restoredOrder
|
|
||||||
currentPlaying.value = newCurrentPos !== -1 ? newCurrentPos : 0
|
|
||||||
} */
|
|
||||||
|
|
||||||
isShuffle.value = newShuffleState
|
// 监听加载完成
|
||||||
|
audio.addEventListener('canplaythrough', () => {
|
||||||
|
preloadedAudio.value.set(songId, audio)
|
||||||
|
isPreloading.value = false
|
||||||
|
preloadProgress.value = 100
|
||||||
|
console.log('[Store] 预加载完成:', updatedSong.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听加载错误
|
||||||
|
audio.addEventListener('error', (e) => {
|
||||||
|
console.error(`[Store] 预加载音频失败: ${updatedSong.name}`, e)
|
||||||
|
isPreloading.value = false
|
||||||
|
preloadProgress.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用更新后的音频源
|
||||||
|
audio.src = updatedSong.sourceUrl!
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Store] 预加载过程出错:', error)
|
||||||
|
isPreloading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换循环播放模式
|
// 获取预加载的音频对象
|
||||||
const toggleLoop = (mode?: 'single' | 'all' | 'off') => {
|
const getPreloadedAudio = (songId: string): HTMLAudioElement | null => {
|
||||||
// 如果指定了循环模式
|
const audio = preloadedAudio.value.get(songId) || null
|
||||||
if (mode) return (loopingMode.value = mode)
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有指定,那么按照「无 -> 列表循环 -> 单曲循环」的顺序轮换
|
// 清理预加载的音频
|
||||||
switch (loopingMode.value) {
|
const clearPreloadedAudio = (songId: string) => {
|
||||||
case 'off':
|
const audio = preloadedAudio.value.get(songId)
|
||||||
loopingMode.value = 'all'
|
if (audio) {
|
||||||
break
|
audio.pause()
|
||||||
case 'all':
|
audio.src = ''
|
||||||
loopingMode.value = 'single'
|
preloadedAudio.value.delete(songId)
|
||||||
break
|
|
||||||
case 'single':
|
|
||||||
loopingMode.value = 'off'
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理所有预加载的音频
|
||||||
|
const clearAllPreloadedAudio = () => {
|
||||||
|
preloadedAudio.value.forEach((_audio, songId) => {
|
||||||
|
clearPreloadedAudio(songId)
|
||||||
|
})
|
||||||
|
preloadedAudio.value.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制预加载缓存大小(最多保留3首歌)
|
||||||
|
const limitPreloadCache = () => {
|
||||||
|
while (preloadedAudio.value.size > 3) {
|
||||||
|
const oldestKey = preloadedAudio.value.keys().next().value
|
||||||
|
if (oldestKey) {
|
||||||
|
clearPreloadedAudio(oldestKey)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试函数:打印当前状态
|
||||||
|
const debugPreloadState = () => {
|
||||||
|
console.log('[Store] 预加载状态:', {
|
||||||
|
isPreloading: isPreloading.value,
|
||||||
|
progress: preloadProgress.value,
|
||||||
|
cacheSize: preloadedAudio.value.size,
|
||||||
|
cachedSongs: Array.from(preloadedAudio.value.keys()),
|
||||||
|
nextIndex: getNextIndex.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 响应式状态(只读)
|
list,
|
||||||
queue: queueState,
|
currentIndex,
|
||||||
isShuffle: shuffleState,
|
isPlaying,
|
||||||
loopMode: loopModeState,
|
queueReplaceLock,
|
||||||
currentTrack,
|
isBuffering,
|
||||||
currentIndex: currentPlaying,
|
currentTime,
|
||||||
previousTrack,
|
duration,
|
||||||
nextTrack,
|
updatedCurrentTime,
|
||||||
|
visualizer,
|
||||||
// 修改方法
|
shuffleList,
|
||||||
replaceQueue,
|
playMode,
|
||||||
toggleShuffle,
|
shuffleCurrent,
|
||||||
toggleLoop,
|
// 预加载相关 - 确保所有函数都在返回对象中
|
||||||
toggleQueuePlay,
|
preloadedAudio,
|
||||||
continueToNext,
|
isPreloading,
|
||||||
|
preloadProgress,
|
||||||
|
getNextIndex,
|
||||||
|
preloadNext,
|
||||||
|
getPreloadedAudio,
|
||||||
|
clearPreloadedAudio,
|
||||||
|
clearAllPreloadedAudio,
|
||||||
|
limitPreloadCache,
|
||||||
|
debugPreloadState,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { debugStore } from '../utils/debug'
|
|
||||||
|
|
||||||
export const usePlayState = defineStore('playState', () => {
|
|
||||||
// 播放状态
|
|
||||||
const isPlaying = ref(false) // 用户控制的播放与暂停
|
|
||||||
const playProgress = ref(0) // 播放进度
|
|
||||||
const currentTrackDuration = ref(0) // 曲目总时长
|
|
||||||
const currentTrack = ref<QueueItem | null>(null) // 当前播放的曲目
|
|
||||||
const actualPlaying = ref(false) // 实际音频的播放与暂停
|
|
||||||
|
|
||||||
// 外显播放状态方法
|
|
||||||
const playingState = computed(() => isPlaying.value)
|
|
||||||
const playProgressState = computed(() => playProgress.value)
|
|
||||||
const trackDurationState = computed(() => currentTrackDuration.value)
|
|
||||||
const actualPlayingState = computed(() => actualPlaying.value)
|
|
||||||
|
|
||||||
// 回报目前播放进度百分比
|
|
||||||
const playProgressPercent = computed(() => {
|
|
||||||
if (currentTrackDuration.value === 0) return 0
|
|
||||||
return Math.min(playProgress.value / currentTrackDuration.value, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 回报剩余时间
|
|
||||||
const remainingTime = computed(() => {
|
|
||||||
return Math.max(currentTrackDuration.value - playProgress.value, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
/***********
|
|
||||||
* 修改状态
|
|
||||||
**********/
|
|
||||||
// 触发播放
|
|
||||||
const togglePlay = (turnTo?: boolean) => {
|
|
||||||
const newPlayState = turnTo ?? !isPlaying.value
|
|
||||||
if (newPlayState === isPlaying.value) return
|
|
||||||
isPlaying.value = newPlayState
|
|
||||||
debugStore(`播放状态更新: ${newPlayState}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回报播放位置
|
|
||||||
const reportPlayProgress = (progress: number) => {
|
|
||||||
playProgress.value = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回报曲目长度
|
|
||||||
const reportCurrentTrackDuration = (duration: number) => {
|
|
||||||
currentTrackDuration.value = duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置播放进度
|
|
||||||
const resetProgress = () => {
|
|
||||||
debugStore('重置播放进度')
|
|
||||||
playProgress.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户触发进度条跳转
|
|
||||||
const seekTo = (time: number) => {
|
|
||||||
const clampedTime = Math.max(0, Math.min(time, currentTrackDuration.value))
|
|
||||||
debugStore(`进度条跳转: ${clampedTime}`)
|
|
||||||
playProgress.value = clampedTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回报 Web Audio API 正在播放
|
|
||||||
const reportActualPlaying = (playing: boolean) => {
|
|
||||||
actualPlaying.value = playing
|
|
||||||
if (playing) isPlaying.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态读取
|
|
||||||
isPlaying: playingState,
|
|
||||||
playProgress: playProgressState,
|
|
||||||
trackDuration: trackDurationState,
|
|
||||||
playProgressPercent,
|
|
||||||
remainingTime,
|
|
||||||
currentTrack: computed(() => currentTrack.value),
|
|
||||||
actualPlaying: actualPlayingState,
|
|
||||||
|
|
||||||
// 修改方法
|
|
||||||
togglePlay,
|
|
||||||
reportPlayProgress,
|
|
||||||
reportCurrentTrackDuration,
|
|
||||||
resetProgress,
|
|
||||||
seekTo,
|
|
||||||
reportActualPlaying,
|
|
||||||
}
|
|
||||||
})
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from "pinia"
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from "vue"
|
||||||
|
|
||||||
// 声明全局类型
|
// 声明全局类型
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -20,18 +20,14 @@ export const usePreferences = defineStore('preferences', () => {
|
||||||
const defaultPreferences = {
|
const defaultPreferences = {
|
||||||
displayTimeLeft: false,
|
displayTimeLeft: false,
|
||||||
presentLyrics: false,
|
presentLyrics: false,
|
||||||
autoRedirect: true,
|
autoRedirect: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测可用的 API
|
// 检测可用的 API
|
||||||
const detectAvailableAPIs = () => {
|
const detectAvailableAPIs = () => {
|
||||||
// 检查原生 chrome API
|
// 检查原生 chrome API
|
||||||
try {
|
try {
|
||||||
if (
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||||
typeof chrome !== 'undefined' &&
|
|
||||||
chrome.storage &&
|
|
||||||
chrome.storage.sync
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -41,11 +37,7 @@ export const usePreferences = defineStore('preferences', () => {
|
||||||
|
|
||||||
// 检查 window.chrome
|
// 检查 window.chrome
|
||||||
try {
|
try {
|
||||||
if (
|
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
|
||||||
window.chrome &&
|
|
||||||
window.chrome.storage &&
|
|
||||||
window.chrome.storage.sync
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -151,7 +143,7 @@ export const usePreferences = defineStore('preferences', () => {
|
||||||
const preferences = {
|
const preferences = {
|
||||||
displayTimeLeft: displayTimeLeft.value,
|
displayTimeLeft: displayTimeLeft.value,
|
||||||
presentLyrics: presentLyrics.value,
|
presentLyrics: presentLyrics.value,
|
||||||
autoRedirect: autoRedirect.value,
|
autoRedirect: autoRedirect.value
|
||||||
}
|
}
|
||||||
await setStoredValue('preferences', preferences)
|
await setStoredValue('preferences', preferences)
|
||||||
}
|
}
|
||||||
|
@ -196,6 +188,6 @@ export const usePreferences = defineStore('preferences', () => {
|
||||||
getStoredValue,
|
getStoredValue,
|
||||||
setStoredValue,
|
setStoredValue,
|
||||||
getPreferences,
|
getPreferences,
|
||||||
savePreferences,
|
savePreferences
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -1,6 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from "pinia"
|
||||||
import { ref } from 'vue'
|
import { ref } from "vue"
|
||||||
import { debugStore } from '../utils/debug'
|
|
||||||
|
|
||||||
// 声明全局类型
|
// 声明全局类型
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -27,11 +26,7 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
const detectAvailableAPIs = () => {
|
const detectAvailableAPIs = () => {
|
||||||
// 检查原生 chrome API
|
// 检查原生 chrome API
|
||||||
try {
|
try {
|
||||||
if (
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||||
typeof chrome !== 'undefined' &&
|
|
||||||
chrome.storage &&
|
|
||||||
chrome.storage.sync
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -41,11 +36,7 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
|
|
||||||
// 检查 window.chrome
|
// 检查 window.chrome
|
||||||
try {
|
try {
|
||||||
if (
|
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
|
||||||
window.chrome &&
|
|
||||||
window.chrome.storage &&
|
|
||||||
window.chrome.storage.sync
|
|
||||||
) {
|
|
||||||
storageType.value = 'chrome'
|
storageType.value = 'chrome'
|
||||||
return 'chrome'
|
return 'chrome'
|
||||||
}
|
}
|
||||||
|
@ -145,17 +136,14 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
const shouldShowUpdatePopup = async (): Promise<boolean> => {
|
const shouldShowUpdatePopup = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const currentVersion = getCurrentVersion()
|
const currentVersion = getCurrentVersion()
|
||||||
|
|
||||||
// 如果无法获取当前版本,不显示弹窗
|
// 如果无法获取当前版本,不显示弹窗
|
||||||
if (currentVersion === 'unknown') {
|
if (currentVersion === 'unknown') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取上次显示弹窗的版本号
|
// 获取上次显示弹窗的版本号
|
||||||
const lastShownVersion = await getStoredValue(
|
const lastShownVersion = await getStoredValue('lastUpdatePopupVersion', '')
|
||||||
'lastUpdatePopupVersion',
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
|
|
||||||
// 如果版本号不同,需要显示弹窗并更新存储的版本号
|
// 如果版本号不同,需要显示弹窗并更新存储的版本号
|
||||||
if (lastShownVersion !== currentVersion) {
|
if (lastShownVersion !== currentVersion) {
|
||||||
|
@ -165,7 +153,7 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugStore('检查更新弹窗状态失败', error)
|
console.error('检查更新弹窗状态失败:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -178,7 +166,7 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
await setStoredValue('lastUpdatePopupVersion', currentVersion)
|
await setStoredValue('lastUpdatePopupVersion', currentVersion)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugStore('标记更新弹窗已显示失败', error)
|
console.error('标记更新弹窗已显示失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,7 +182,7 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
detectAvailableAPIs()
|
detectAvailableAPIs()
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugStore('初始化更新弹窗 store 失败', error)
|
console.error('初始化更新弹窗 store 失败:', error)
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -211,6 +199,6 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
|
||||||
getLastShownVersion,
|
getLastShownVersion,
|
||||||
initializeUpdatePopup,
|
initializeUpdatePopup,
|
||||||
getStoredValue,
|
getStoredValue,
|
||||||
setStoredValue,
|
setStoredValue
|
||||||
}
|
}
|
||||||
})
|
})
|
|
@ -1,15 +1,15 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
/* 导入来自 /assets/MiSans_VF.ttf 的字体 */
|
/* 导入来自 /assets/MiSans_VF.ttf 的字体 */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "MiSans";
|
font-family: 'MiSans';
|
||||||
src: url("/assets/MiSans_VF.ttf") format("truetype-variations");
|
src: url('/assets/MiSans_VF.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 1 999;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Alte DIN";
|
font-family: 'Alte DIN';
|
||||||
src: url("/assets/din1451alt.ttf") format("truetype-variations");
|
src: url('/assets/din1451alt.ttf') format('truetype-variations');
|
||||||
font-weight: 1 999;
|
font-weight: 1 999;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
@ -27,5 +27,5 @@ input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.track_num {
|
.track_num {
|
||||||
font-family: "DIN Alternate", "Alte DIN" !important;
|
font-family: 'DIN Alternate', 'Alte DIN' !important;
|
||||||
}
|
}
|
|
@ -1,10 +1,6 @@
|
||||||
export default (list: string[]) => {
|
export default (list: string[]) => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) { return '未知音乐人' }
|
||||||
return '未知音乐人'
|
return list.map((artist) => {
|
||||||
}
|
return artist
|
||||||
return list
|
}).join(' / ')
|
||||||
.map((artist) => {
|
}
|
||||||
return artist
|
|
||||||
})
|
|
||||||
.join(' / ')
|
|
||||||
}
|
|
|
@ -1,419 +1,378 @@
|
||||||
// utils/audioVisualizer.ts - 平衡频谱版本
|
// utils/audioVisualizer.ts - 平衡频谱版本
|
||||||
import { ref, onUnmounted, Ref } from 'vue'
|
import { ref, onUnmounted, Ref } from 'vue'
|
||||||
import { debugVisualizer } from './debug'
|
|
||||||
|
|
||||||
interface AudioVisualizerOptions {
|
interface AudioVisualizerOptions {
|
||||||
sensitivity?: number
|
sensitivity?: number
|
||||||
smoothing?: number
|
smoothing?: number
|
||||||
barCount?: number
|
barCount?: number
|
||||||
debug?: boolean
|
debug?: boolean
|
||||||
bassBoost?: number // 低音增强倍数 (默认 0.7,降低低音)
|
bassBoost?: number // 低音增强倍数 (默认 0.7,降低低音)
|
||||||
midBoost?: number // 中音增强倍数 (默认 1.2)
|
midBoost?: number // 中音增强倍数 (默认 1.2)
|
||||||
trebleBoost?: number // 高音增强倍数 (默认 1.5)
|
trebleBoost?: number // 高音增强倍数 (默认 1.5)
|
||||||
threshold?: number // 响度门槛 (0-255,默认 15)
|
threshold?: number // 响度门槛 (0-255,默认 15)
|
||||||
minHeight?: number // 最小高度百分比 (默认 0)
|
minHeight?: number // 最小高度百分比 (默认 0)
|
||||||
maxDecibels?: number // 最大分贝门槛 (默认 -10,越大越难顶满)
|
maxDecibels?: number // 最大分贝门槛 (默认 -10,越大越难顶满)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function audioVisualizer(options: AudioVisualizerOptions = {}) {
|
export function audioVisualizer(options: AudioVisualizerOptions = {}) {
|
||||||
const {
|
const {
|
||||||
sensitivity = 1,
|
sensitivity = 1,
|
||||||
smoothing = 0.7,
|
smoothing = 0.7,
|
||||||
barCount = 4,
|
barCount = 4,
|
||||||
debug = false,
|
debug = false,
|
||||||
bassBoost = 0.7, // 降低低音权重
|
bassBoost = 0.7, // 降低低音权重
|
||||||
midBoost = 1.2, // 提升中音
|
midBoost = 1.2, // 提升中音
|
||||||
trebleBoost = 1.5, // 提升高音
|
trebleBoost = 1.5, // 提升高音
|
||||||
threshold = 15, // 响度门槛,低于此值不产生波动
|
threshold = 15, // 响度门槛,低于此值不产生波动
|
||||||
minHeight = 0, // 最小高度百分比
|
minHeight = 0 // 最小高度百分比
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
debugVisualizer('初始化平衡频谱', options)
|
console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options)
|
||||||
|
|
||||||
// 导出的竖杠高度值数组 (0-100)
|
// 导出的竖杠高度值数组 (0-100)
|
||||||
const barHeights: Ref<number[]> = ref(Array(barCount).fill(0))
|
const barHeights: Ref<number[]> = ref(Array(barCount).fill(0))
|
||||||
const isAnalyzing = ref(false)
|
const isAnalyzing = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isInitialized = ref(false)
|
const isInitialized = ref(false)
|
||||||
|
|
||||||
// 内部变量
|
// 内部变量
|
||||||
let audioContext: AudioContext | null = null
|
let audioContext: AudioContext | null = null
|
||||||
let analyser: AnalyserNode | null = null
|
let analyser: AnalyserNode | null = null
|
||||||
let source: MediaElementAudioSourceNode | null = null
|
let source: MediaElementAudioSourceNode | null = null
|
||||||
let dataArray: Uint8Array | null = null
|
let dataArray: Uint8Array | null = null
|
||||||
let animationId: number | null = null
|
let animationId: number | null = null
|
||||||
let currentAudioElement: HTMLAudioElement | null = null
|
let currentAudioElement: HTMLAudioElement | null = null
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
function log(...args: any[]) {
|
function log(...args: any[]) {
|
||||||
if (debug) {
|
if (debug) {
|
||||||
debugVisualizer(...args)
|
console.log('[AudioVisualizer]', ...args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化音频分析
|
// 初始化音频分析
|
||||||
function initAudioContext(audioElement: HTMLAudioElement) {
|
function initAudioContext(audioElement: HTMLAudioElement) {
|
||||||
if (!audioElement) {
|
if (!audioElement) {
|
||||||
log('错误: 音频元素为空')
|
log('错误: 音频元素为空')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (audioContext) {
|
||||||
|
log('AudioContext 已存在,跳过初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (audioContext) {
|
try {
|
||||||
log('AudioContext 已存在,跳过初始化')
|
log('开始初始化音频上下文...')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||||
log('开始初始化音频上下文...')
|
log('AudioContext 创建成功, 状态:', audioContext.state, '采样率:', audioContext.sampleRate)
|
||||||
|
|
||||||
audioContext = new (
|
// 如果上下文被暂停,尝试恢复
|
||||||
window.AudioContext || (window as any).webkitAudioContext
|
if (audioContext.state === 'suspended') {
|
||||||
)()
|
audioContext.resume().then(() => {
|
||||||
log(
|
log('AudioContext 已恢复')
|
||||||
'AudioContext 创建成功, 状态:',
|
})
|
||||||
audioContext.state,
|
}
|
||||||
'采样率:',
|
|
||||||
audioContext.sampleRate,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 如果上下文被暂停,尝试恢复
|
analyser = audioContext.createAnalyser()
|
||||||
if (audioContext.state === 'suspended') {
|
|
||||||
audioContext.resume().then(() => {
|
// 尝试创建音频源
|
||||||
log('AudioContext 已恢复')
|
try {
|
||||||
})
|
source = audioContext.createMediaElementSource(audioElement)
|
||||||
}
|
log('MediaElementSource 创建成功')
|
||||||
|
} catch (sourceError) {
|
||||||
|
log('创建 MediaElementSource 失败:', sourceError)
|
||||||
|
error.value = 'CORS 错误: 无法访问跨域音频'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化分析器配置
|
||||||
|
analyser.fftSize = 2048 // 增加分辨率
|
||||||
|
analyser.smoothingTimeConstant = smoothing
|
||||||
|
analyser.minDecibels = -100 // 更低的最小分贝
|
||||||
|
analyser.maxDecibels = options.maxDecibels || -10 // 使用配置的最大分贝门槛
|
||||||
|
|
||||||
|
log('分析器配置:', {
|
||||||
|
fftSize: analyser.fftSize,
|
||||||
|
frequencyBinCount: analyser.frequencyBinCount,
|
||||||
|
sampleRate: audioContext.sampleRate,
|
||||||
|
frequencyResolution: audioContext.sampleRate / analyser.fftSize,
|
||||||
|
maxDecibels: analyser.maxDecibels
|
||||||
|
})
|
||||||
|
|
||||||
|
// 连接音频节点
|
||||||
|
source.connect(analyser)
|
||||||
|
analyser.connect(audioContext.destination)
|
||||||
|
|
||||||
|
// 创建数据数组
|
||||||
|
dataArray = new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
|
||||||
|
isInitialized.value = true
|
||||||
|
error.value = null
|
||||||
|
log('✅ 音频可视化器初始化成功')
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log('❌ 音频上下文初始化失败:', err)
|
||||||
|
error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
isInitialized.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
analyser = audioContext.createAnalyser()
|
// 开始分析
|
||||||
|
function startAnalysis() {
|
||||||
|
if (!analyser || !dataArray || !isInitialized.value) {
|
||||||
|
log('❌ 无法开始分析: 分析器未初始化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log('✅ 开始频谱分析')
|
||||||
|
isAnalyzing.value = true
|
||||||
|
animate()
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试创建音频源
|
// 停止分析
|
||||||
try {
|
function stopAnalysis() {
|
||||||
source = audioContext.createMediaElementSource(audioElement)
|
log('停止频谱分析')
|
||||||
log('MediaElementSource 创建成功')
|
isAnalyzing.value = false
|
||||||
} catch (sourceError) {
|
if (animationId) {
|
||||||
log('创建 MediaElementSource 失败:', sourceError)
|
cancelAnimationFrame(animationId)
|
||||||
error.value = 'CORS 错误: 无法访问跨域音频'
|
animationId = null
|
||||||
return
|
}
|
||||||
}
|
barHeights.value = Array(barCount).fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
// 优化分析器配置
|
// 动画循环
|
||||||
analyser.fftSize = 2048 // 增加分辨率
|
function animate() {
|
||||||
analyser.smoothingTimeConstant = smoothing
|
if (!isAnalyzing.value || !analyser || !dataArray || !audioContext) return
|
||||||
analyser.minDecibels = -100 // 更低的最小分贝
|
|
||||||
analyser.maxDecibels = options.maxDecibels || -10 // 使用配置的最大分贝门槛
|
// 获取频率数据
|
||||||
|
analyser.getByteFrequencyData(dataArray)
|
||||||
|
|
||||||
|
// 使用平衡的频段分割
|
||||||
|
const frequencyBands = divideFrequencyBandsBalanced(dataArray, barCount, audioContext.sampleRate)
|
||||||
|
|
||||||
|
// 应用频段特定的增强
|
||||||
|
const enhancedBands = applyFrequencyEnhancement(frequencyBands)
|
||||||
|
|
||||||
|
// 更新竖杠高度 (0-100)
|
||||||
|
barHeights.value = enhancedBands.map(value =>
|
||||||
|
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity))
|
||||||
|
)
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
log('分析器配置:', {
|
// 平衡的频段分割 - 使用对数分布和人耳感知特性
|
||||||
fftSize: analyser.fftSize,
|
function divideFrequencyBandsBalanced(data: Uint8Array, bands: number, sampleRate: number): number[] {
|
||||||
frequencyBinCount: analyser.frequencyBinCount,
|
const nyquist = sampleRate / 2
|
||||||
sampleRate: audioContext.sampleRate,
|
const result: number[] = []
|
||||||
frequencyResolution: audioContext.sampleRate / analyser.fftSize,
|
|
||||||
maxDecibels: analyser.maxDecibels,
|
// 定义人耳感知的频率范围 (Hz)
|
||||||
})
|
const frequencyRanges = [
|
||||||
|
{ min: 20, max: 80, name: '超低音' }, // 索引 0
|
||||||
// 连接音频节点
|
{ min: 80, max: 250, name: '低音' }, // 索引 1
|
||||||
source.connect(analyser)
|
{ min: 250, max: 800, name: '中低音' }, // 索引 2
|
||||||
analyser.connect(audioContext.destination)
|
{ min: 800, max: 2500, name: '中音' }, // 索引 3
|
||||||
|
{ min: 2500, max: 6000, name: '中高音' }, // 索引 4
|
||||||
// 创建数据数组
|
{ min: 6000, max: 20000, name: '高音' } // 索引 5
|
||||||
dataArray = new Uint8Array(analyser.frequencyBinCount)
|
|
||||||
|
|
||||||
isInitialized.value = true
|
|
||||||
error.value = null
|
|
||||||
log('✅ 音频可视化器初始化成功')
|
|
||||||
} catch (err) {
|
|
||||||
log('❌ 音频上下文初始化失败:', err)
|
|
||||||
error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}`
|
|
||||||
isInitialized.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始分析
|
|
||||||
function startAnalysis() {
|
|
||||||
if (!analyser || !dataArray || !isInitialized.value) {
|
|
||||||
log('❌ 无法开始分析: 分析器未初始化')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log('✅ 开始频谱分析')
|
|
||||||
isAnalyzing.value = true
|
|
||||||
animate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止分析
|
|
||||||
function stopAnalysis() {
|
|
||||||
log('停止频谱分析')
|
|
||||||
isAnalyzing.value = false
|
|
||||||
if (animationId) {
|
|
||||||
cancelAnimationFrame(animationId)
|
|
||||||
animationId = null
|
|
||||||
}
|
|
||||||
barHeights.value = Array(barCount).fill(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动画循环
|
|
||||||
function animate() {
|
|
||||||
if (!isAnalyzing.value || !analyser || !dataArray || !audioContext) return
|
|
||||||
|
|
||||||
// 获取频率数据
|
|
||||||
analyser.getByteFrequencyData(dataArray)
|
|
||||||
|
|
||||||
// 使用平衡的频段分割
|
|
||||||
const frequencyBands = divideFrequencyBandsBalanced(
|
|
||||||
dataArray,
|
|
||||||
barCount,
|
|
||||||
audioContext.sampleRate,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 应用频段特定的增强
|
|
||||||
const enhancedBands = applyFrequencyEnhancement(frequencyBands)
|
|
||||||
|
|
||||||
// 更新竖杠高度 (0-100)
|
|
||||||
barHeights.value = enhancedBands.map((value) =>
|
|
||||||
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity)),
|
|
||||||
)
|
|
||||||
|
|
||||||
animationId = requestAnimationFrame(animate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 平衡的频段分割 - 使用对数分布和人耳感知特性
|
|
||||||
function divideFrequencyBandsBalanced(
|
|
||||||
data: Uint8Array,
|
|
||||||
bands: number,
|
|
||||||
sampleRate: number,
|
|
||||||
): number[] {
|
|
||||||
const nyquist = sampleRate / 2
|
|
||||||
const result: number[] = []
|
|
||||||
|
|
||||||
// 定义人耳感知的频率范围 (Hz)
|
|
||||||
const frequencyRanges = [
|
|
||||||
{ min: 20, max: 80, name: '超低音' }, // 索引 0
|
|
||||||
{ min: 80, max: 250, name: '低音' }, // 索引 1
|
|
||||||
{ min: 250, max: 800, name: '中低音' }, // 索引 2
|
|
||||||
{ min: 800, max: 2500, name: '中音' }, // 索引 3
|
|
||||||
{ min: 2500, max: 6000, name: '中高音' }, // 索引 4
|
|
||||||
{ min: 6000, max: 20000, name: '高音' }, // 索引 5
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
for (let i = 0; i < bands; i++) {
|
||||||
|
const range = frequencyRanges[i] || frequencyRanges[frequencyRanges.length - 1]
|
||||||
|
|
||||||
|
// 将频率转换为 bin 索引
|
||||||
|
const startBin = Math.floor((range.min / nyquist) * data.length)
|
||||||
|
const endBin = Math.floor((range.max / nyquist) * data.length)
|
||||||
|
|
||||||
|
// 确保范围有效
|
||||||
|
const actualStart = Math.max(0, startBin)
|
||||||
|
const actualEnd = Math.min(data.length - 1, endBin)
|
||||||
|
|
||||||
|
if (debug && Math.random() < 0.01) {
|
||||||
|
log(`频段 ${i} (${range.name}): ${range.min}-${range.max}Hz, bins ${actualStart}-${actualEnd}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算该频段的 RMS (均方根) 值,而不是简单平均
|
||||||
|
let sumSquares = 0
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (let j = actualStart; j <= actualEnd; j++) {
|
||||||
|
const value = data[j]
|
||||||
|
sumSquares += value * value
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = count > 0 ? Math.sqrt(sumSquares / count) : 0
|
||||||
|
result.push(rms)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < bands; i++) {
|
// 应用频段特定的增强和门槛
|
||||||
const range =
|
function applyFrequencyEnhancement(bands: number[]): number[] {
|
||||||
frequencyRanges[i] || frequencyRanges[frequencyRanges.length - 1]
|
// 六个频段的增强倍数
|
||||||
|
const boosts = [bassBoost, bassBoost, midBoost, midBoost, trebleBoost, trebleBoost]
|
||||||
|
|
||||||
|
return bands.map((value, index) => {
|
||||||
|
// 应用响度门槛
|
||||||
|
if (value < threshold) {
|
||||||
|
if (debug && Math.random() < 0.01) {
|
||||||
|
log(`频段 ${index} 低于门槛: ${Math.round(value)} < ${threshold}`)
|
||||||
|
}
|
||||||
|
return minHeight * 255 / 100 // 返回最小高度对应的值
|
||||||
|
}
|
||||||
|
|
||||||
|
const boost = boosts[index] || 1
|
||||||
|
let enhanced = value * boost
|
||||||
|
|
||||||
|
// 应用压缩曲线,防止过度增强
|
||||||
|
enhanced = 255 * Math.tanh(enhanced / 255)
|
||||||
|
|
||||||
|
return Math.min(255, Math.max(threshold, enhanced))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 将频率转换为 bin 索引
|
// 连接音频元素
|
||||||
const startBin = Math.floor((range.min / nyquist) * data.length)
|
function connectAudio(audioElement: HTMLAudioElement) {
|
||||||
const endBin = Math.floor((range.max / nyquist) * data.length)
|
log('🔗 连接音频元素...')
|
||||||
|
|
||||||
|
if (currentAudioElement === audioElement) {
|
||||||
|
log('音频元素相同,跳过重复连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧的连接
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
currentAudioElement = audioElement
|
||||||
|
|
||||||
|
// 等待音频加载完成后再初始化
|
||||||
|
if (audioElement.readyState >= 2) {
|
||||||
|
initAudioContext(audioElement)
|
||||||
|
} else {
|
||||||
|
audioElement.addEventListener('loadeddata', () => {
|
||||||
|
initAudioContext(audioElement)
|
||||||
|
}, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听播放状态
|
||||||
|
audioElement.addEventListener('play', startAnalysis)
|
||||||
|
audioElement.addEventListener('pause', stopAnalysis)
|
||||||
|
audioElement.addEventListener('ended', stopAnalysis)
|
||||||
|
|
||||||
|
// 监听错误
|
||||||
|
audioElement.addEventListener('error', (e) => {
|
||||||
|
log('❌ 音频加载错误:', e)
|
||||||
|
error.value = '音频加载失败'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 确保范围有效
|
// 断开音频元素
|
||||||
const actualStart = Math.max(0, startBin)
|
function disconnectAudio() {
|
||||||
const actualEnd = Math.min(data.length - 1, endBin)
|
if (currentAudioElement) {
|
||||||
|
currentAudioElement.removeEventListener('play', startAnalysis)
|
||||||
|
currentAudioElement.removeEventListener('pause', stopAnalysis)
|
||||||
|
currentAudioElement.removeEventListener('ended', stopAnalysis)
|
||||||
|
currentAudioElement = null
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
if (debug && Math.random() < 0.01) {
|
// 清理资源
|
||||||
log(
|
function cleanup() {
|
||||||
`频段 ${i} (${range.name}): ${range.min}-${range.max}Hz, bins ${actualStart}-${actualEnd}`,
|
stopAnalysis()
|
||||||
)
|
if (audioContext && audioContext.state !== 'closed') {
|
||||||
}
|
audioContext.close()
|
||||||
|
}
|
||||||
|
audioContext = null
|
||||||
|
analyser = null
|
||||||
|
source = null
|
||||||
|
dataArray = null
|
||||||
|
isInitialized.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 计算该频段的 RMS (均方根) 值,而不是简单平均
|
// 手动测试数据
|
||||||
let sumSquares = 0
|
function testWithFakeData() {
|
||||||
let count = 0
|
log('🧪 开始六频段模拟测试')
|
||||||
|
isAnalyzing.value = true
|
||||||
|
|
||||||
|
let testCount = 0
|
||||||
|
const maxTests = 50
|
||||||
|
|
||||||
|
const fakeInterval = setInterval(() => {
|
||||||
|
// 模拟六个频段的数据
|
||||||
|
barHeights.value = [
|
||||||
|
Math.random() * 50 + 10, // 超低音:10-60
|
||||||
|
Math.random() * 60 + 20, // 低音:20-80
|
||||||
|
Math.random() * 70 + 15, // 中低音:15-85
|
||||||
|
Math.random() * 80 + 10, // 中音:10-90
|
||||||
|
Math.random() * 75 + 10, // 中高音:10-85
|
||||||
|
Math.random() * 65 + 15 // 高音:15-80
|
||||||
|
]
|
||||||
|
testCount++
|
||||||
|
|
||||||
|
if (testCount >= maxTests) {
|
||||||
|
clearInterval(fakeInterval)
|
||||||
|
barHeights.value = Array(barCount).fill(0)
|
||||||
|
isAnalyzing.value = false
|
||||||
|
log('🧪 模拟测试结束')
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
for (let j = actualStart; j <= actualEnd; j++) {
|
// 动态调整增强参数和门槛
|
||||||
const value = data[j]
|
function updateEnhancement(bass: number, mid: number, treble: number, newThreshold?: number, newMaxDecibels?: number) {
|
||||||
sumSquares += value * value
|
options.bassBoost = bass
|
||||||
count++
|
options.midBoost = mid
|
||||||
}
|
options.trebleBoost = treble
|
||||||
|
if (newThreshold !== undefined) {
|
||||||
|
options.threshold = newThreshold
|
||||||
|
}
|
||||||
|
if (newMaxDecibels !== undefined) {
|
||||||
|
options.maxDecibels = newMaxDecibels
|
||||||
|
// 如果分析器已经初始化,更新其配置
|
||||||
|
if (analyser) {
|
||||||
|
analyser.maxDecibels = newMaxDecibels
|
||||||
|
log('实时更新 maxDecibels:', newMaxDecibels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('更新频段增强:', { bass, mid, treble, threshold: options.threshold, maxDecibels: options.maxDecibels })
|
||||||
|
}
|
||||||
|
|
||||||
const rms = count > 0 ? Math.sqrt(sumSquares / count) : 0
|
// 设置响度门槛
|
||||||
result.push(rms)
|
function setThreshold(newThreshold: number) {
|
||||||
}
|
options.threshold = Math.max(0, Math.min(255, newThreshold))
|
||||||
|
log('更新响度门槛:', options.threshold)
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
// 设置最大分贝门槛
|
||||||
}
|
function setMaxDecibels(newMaxDecibels: number) {
|
||||||
|
options.maxDecibels = Math.max(-100, Math.min(0, newMaxDecibels))
|
||||||
|
if (analyser) {
|
||||||
|
analyser.maxDecibels = options.maxDecibels
|
||||||
|
}
|
||||||
|
log('更新最大分贝门槛:', options.maxDecibels)
|
||||||
|
}
|
||||||
|
|
||||||
// 应用频段特定的增强和门槛
|
// 组件卸载时清理
|
||||||
function applyFrequencyEnhancement(bands: number[]): number[] {
|
onUnmounted(() => {
|
||||||
// 六个频段的增强倍数
|
disconnectAudio()
|
||||||
const boosts = [
|
})
|
||||||
bassBoost,
|
|
||||||
bassBoost,
|
|
||||||
midBoost,
|
|
||||||
midBoost,
|
|
||||||
trebleBoost,
|
|
||||||
trebleBoost,
|
|
||||||
]
|
|
||||||
|
|
||||||
return bands.map((value, index) => {
|
return {
|
||||||
// 应用响度门槛
|
barHeights,
|
||||||
if (value < threshold) {
|
isAnalyzing,
|
||||||
if (debug && Math.random() < 0.01) {
|
isInitialized,
|
||||||
log(`频段 ${index} 低于门槛: ${Math.round(value)} < ${threshold}`)
|
error,
|
||||||
}
|
connectAudio,
|
||||||
return (minHeight * 255) / 100 // 返回最小高度对应的值
|
disconnectAudio,
|
||||||
}
|
startAnalysis,
|
||||||
|
stopAnalysis,
|
||||||
const boost = boosts[index] || 1
|
testWithFakeData,
|
||||||
let enhanced = value * boost
|
updateEnhancement,
|
||||||
|
setThreshold,
|
||||||
// 应用压缩曲线,防止过度增强
|
setMaxDecibels
|
||||||
enhanced = 255 * Math.tanh(enhanced / 255)
|
}
|
||||||
|
}
|
||||||
return Math.min(255, Math.max(threshold, enhanced))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连接音频元素
|
|
||||||
function connectAudio(audioElement: HTMLAudioElement) {
|
|
||||||
log('🔗 连接音频元素...')
|
|
||||||
|
|
||||||
if (currentAudioElement === audioElement) {
|
|
||||||
log('音频元素相同,跳过重复连接')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理旧的连接
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
currentAudioElement = audioElement
|
|
||||||
|
|
||||||
// 等待音频加载完成后再初始化
|
|
||||||
if (audioElement.readyState >= 2) {
|
|
||||||
initAudioContext(audioElement)
|
|
||||||
} else {
|
|
||||||
audioElement.addEventListener(
|
|
||||||
'loadeddata',
|
|
||||||
() => {
|
|
||||||
initAudioContext(audioElement)
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听播放状态
|
|
||||||
audioElement.addEventListener('play', startAnalysis)
|
|
||||||
audioElement.addEventListener('pause', stopAnalysis)
|
|
||||||
audioElement.addEventListener('ended', stopAnalysis)
|
|
||||||
|
|
||||||
// 监听错误
|
|
||||||
audioElement.addEventListener('error', (e) => {
|
|
||||||
log('❌ 音频加载错误:', e)
|
|
||||||
error.value = '音频加载失败'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 断开音频元素
|
|
||||||
function disconnectAudio() {
|
|
||||||
if (currentAudioElement) {
|
|
||||||
currentAudioElement.removeEventListener('play', startAnalysis)
|
|
||||||
currentAudioElement.removeEventListener('pause', stopAnalysis)
|
|
||||||
currentAudioElement.removeEventListener('ended', stopAnalysis)
|
|
||||||
currentAudioElement = null
|
|
||||||
}
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理资源
|
|
||||||
function cleanup() {
|
|
||||||
stopAnalysis()
|
|
||||||
if (audioContext && audioContext.state !== 'closed') {
|
|
||||||
audioContext.close()
|
|
||||||
}
|
|
||||||
audioContext = null
|
|
||||||
analyser = null
|
|
||||||
source = null
|
|
||||||
dataArray = null
|
|
||||||
isInitialized.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 手动测试数据
|
|
||||||
function testWithFakeData() {
|
|
||||||
log('🧪 开始六频段模拟测试')
|
|
||||||
isAnalyzing.value = true
|
|
||||||
|
|
||||||
let testCount = 0
|
|
||||||
const maxTests = 50
|
|
||||||
|
|
||||||
const fakeInterval = setInterval(() => {
|
|
||||||
// 模拟六个频段的数据
|
|
||||||
barHeights.value = [
|
|
||||||
Math.random() * 50 + 10, // 超低音:10-60
|
|
||||||
Math.random() * 60 + 20, // 低音:20-80
|
|
||||||
Math.random() * 70 + 15, // 中低音:15-85
|
|
||||||
Math.random() * 80 + 10, // 中音:10-90
|
|
||||||
Math.random() * 75 + 10, // 中高音:10-85
|
|
||||||
Math.random() * 65 + 15, // 高音:15-80
|
|
||||||
]
|
|
||||||
testCount++
|
|
||||||
|
|
||||||
if (testCount >= maxTests) {
|
|
||||||
clearInterval(fakeInterval)
|
|
||||||
barHeights.value = Array(barCount).fill(0)
|
|
||||||
isAnalyzing.value = false
|
|
||||||
log('🧪 模拟测试结束')
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 动态调整增强参数和门槛
|
|
||||||
function updateEnhancement(
|
|
||||||
bass: number,
|
|
||||||
mid: number,
|
|
||||||
treble: number,
|
|
||||||
newThreshold?: number,
|
|
||||||
newMaxDecibels?: number,
|
|
||||||
) {
|
|
||||||
options.bassBoost = bass
|
|
||||||
options.midBoost = mid
|
|
||||||
options.trebleBoost = treble
|
|
||||||
if (newThreshold !== undefined) {
|
|
||||||
options.threshold = newThreshold
|
|
||||||
}
|
|
||||||
if (newMaxDecibels !== undefined) {
|
|
||||||
options.maxDecibels = newMaxDecibels
|
|
||||||
// 如果分析器已经初始化,更新其配置
|
|
||||||
if (analyser) {
|
|
||||||
analyser.maxDecibels = newMaxDecibels
|
|
||||||
log('实时更新 maxDecibels:', newMaxDecibels)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log('更新频段增强:', {
|
|
||||||
bass,
|
|
||||||
mid,
|
|
||||||
treble,
|
|
||||||
threshold: options.threshold,
|
|
||||||
maxDecibels: options.maxDecibels,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置响度门槛
|
|
||||||
function setThreshold(newThreshold: number) {
|
|
||||||
options.threshold = Math.max(0, Math.min(255, newThreshold))
|
|
||||||
log('更新响度门槛:', options.threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置最大分贝门槛
|
|
||||||
function setMaxDecibels(newMaxDecibels: number) {
|
|
||||||
options.maxDecibels = Math.max(-100, Math.min(0, newMaxDecibels))
|
|
||||||
if (analyser) {
|
|
||||||
analyser.maxDecibels = options.maxDecibels
|
|
||||||
}
|
|
||||||
log('更新最大分贝门槛:', options.maxDecibels)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件卸载时清理
|
|
||||||
onUnmounted(() => {
|
|
||||||
disconnectAudio()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
barHeights,
|
|
||||||
isAnalyzing,
|
|
||||||
isInitialized,
|
|
||||||
error,
|
|
||||||
connectAudio,
|
|
||||||
disconnectAudio,
|
|
||||||
startAnalysis,
|
|
||||||
stopAnalysis,
|
|
||||||
testWithFakeData,
|
|
||||||
updateEnhancement,
|
|
||||||
setThreshold,
|
|
||||||
setMaxDecibels,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* 浏览器检测工具
|
* 浏览器检测工具
|
||||||
*/
|
*/
|
||||||
import { debugUtils } from './debug'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测是否为 Safari 浏览器
|
* 检测是否为 Safari 浏览器
|
||||||
|
@ -9,20 +8,18 @@ import { debugUtils } from './debug'
|
||||||
*/
|
*/
|
||||||
export function isSafari(): boolean {
|
export function isSafari(): boolean {
|
||||||
const ua = navigator.userAgent.toLowerCase()
|
const ua = navigator.userAgent.toLowerCase()
|
||||||
|
|
||||||
// 检测 Safari 浏览器(包括 iOS 和 macOS)
|
// 检测 Safari 浏览器(包括 iOS 和 macOS)
|
||||||
// Safari 的 User Agent 包含 'safari' 但不包含 'chrome' 或 'chromium'
|
// Safari 的 User Agent 包含 'safari' 但不包含 'chrome' 或 'chromium'
|
||||||
const isSafariBrowser =
|
const isSafariBrowser = ua.includes('safari') &&
|
||||||
ua.includes('safari') &&
|
!ua.includes('chrome') &&
|
||||||
!ua.includes('chrome') &&
|
|
||||||
!ua.includes('chromium') &&
|
!ua.includes('chromium') &&
|
||||||
!ua.includes('android')
|
!ua.includes('android')
|
||||||
|
|
||||||
// 额外检查:使用 Safari 特有的 API
|
// 额外检查:使用 Safari 特有的 API
|
||||||
const isSafariByFeature =
|
const isSafariByFeature = 'safari' in window ||
|
||||||
'safari' in window ||
|
|
||||||
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||||
|
|
||||||
return isSafariBrowser || isSafariByFeature
|
return isSafariBrowser || isSafariByFeature
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,9 +28,7 @@ export function isSafari(): boolean {
|
||||||
* @returns {boolean} 如果是移动版 Safari 返回 true,否则返回 false
|
* @returns {boolean} 如果是移动版 Safari 返回 true,否则返回 false
|
||||||
*/
|
*/
|
||||||
export function isMobileSafari(): boolean {
|
export function isMobileSafari(): boolean {
|
||||||
return (
|
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||||
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,19 +39,17 @@ export function supportsWebAudioVisualization(): boolean {
|
||||||
// Safari 在某些情况下对 AudioContext 的支持有限制
|
// Safari 在某些情况下对 AudioContext 的支持有限制
|
||||||
// 特别是在处理跨域音频资源时
|
// 特别是在处理跨域音频资源时
|
||||||
if (isSafari()) {
|
if (isSafari()) {
|
||||||
debugUtils('Safari浏览器检测,音频可视化禁用')
|
console.log('[BrowserDetection] Safari detected, audio visualization disabled')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查基本的 Web Audio API 支持
|
// 检查基本的 Web Audio API 支持
|
||||||
const hasAudioContext =
|
const hasAudioContext = 'AudioContext' in window || 'webkitAudioContext' in window
|
||||||
'AudioContext' in window || 'webkitAudioContext' in window
|
const hasAnalyserNode = hasAudioContext && (
|
||||||
const hasAnalyserNode =
|
'AnalyserNode' in window ||
|
||||||
hasAudioContext &&
|
((window as any).AudioContext && 'createAnalyser' in (window as any).AudioContext.prototype)
|
||||||
('AnalyserNode' in window ||
|
)
|
||||||
((window as any).AudioContext &&
|
|
||||||
'createAnalyser' in (window as any).AudioContext.prototype))
|
|
||||||
|
|
||||||
return hasAudioContext && hasAnalyserNode
|
return hasAudioContext && hasAnalyserNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +61,7 @@ export function getBrowserInfo() {
|
||||||
const ua = navigator.userAgent
|
const ua = navigator.userAgent
|
||||||
let browserName = 'Unknown'
|
let browserName = 'Unknown'
|
||||||
let browserVersion = 'Unknown'
|
let browserVersion = 'Unknown'
|
||||||
|
|
||||||
if (isSafari()) {
|
if (isSafari()) {
|
||||||
browserName = 'Safari'
|
browserName = 'Safari'
|
||||||
const versionMatch = ua.match(/Version\/(\d+\.\d+)/)
|
const versionMatch = ua.match(/Version\/(\d+\.\d+)/)
|
||||||
|
@ -94,12 +87,12 @@ export function getBrowserInfo() {
|
||||||
browserVersion = versionMatch[1]
|
browserVersion = versionMatch[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: browserName,
|
name: browserName,
|
||||||
version: browserVersion,
|
version: browserVersion,
|
||||||
isSafari: isSafari(),
|
isSafari: isSafari(),
|
||||||
isMobileSafari: isMobileSafari(),
|
isMobileSafari: isMobileSafari(),
|
||||||
supportsAudioVisualization: supportsWebAudioVisualization(),
|
supportsAudioVisualization: supportsWebAudioVisualization()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
export default {
|
export default {
|
||||||
runId: import.meta.env.VITE_RUN_ID ?? '未知',
|
runId: import.meta.env.VITE_RUN_ID ?? '未知',
|
||||||
hashId: import.meta.env.VITE_HASH_ID?.slice(0, 10) ?? '未知',
|
hashId: import.meta.env.VITE_HASH_ID?.slice(0, 10) ?? '未知',
|
||||||
}
|
}
|
|
@ -1,30 +0,0 @@
|
||||||
import createDebug from 'debug'
|
|
||||||
|
|
||||||
// 创建不同模块的 debug 实例
|
|
||||||
export const debugPlayer = createDebug('msr:player')
|
|
||||||
export const debugStore = createDebug('msr:store')
|
|
||||||
export const debugApi = createDebug('msr:api')
|
|
||||||
export const debugUI = createDebug('msr:ui')
|
|
||||||
export const debugUtils = createDebug('msr:utils')
|
|
||||||
export const debugVisualizer = createDebug('msr:visualizer')
|
|
||||||
export const debugResource = createDebug('msr:resource')
|
|
||||||
export const debugLyrics = createDebug('msr:lyrics')
|
|
||||||
export const debugPlayroom = createDebug('msr:playroom')
|
|
||||||
|
|
||||||
// 通用 debug 实例
|
|
||||||
export const debug = createDebug('msr:app')
|
|
||||||
|
|
||||||
// 在开发环境下默认启用所有 debug
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
// 从环境变量或 localStorage 读取 DEBUG 设置
|
|
||||||
const debugEnv = import.meta.env.VITE_DEBUG || localStorage.getItem('DEBUG')
|
|
||||||
if (debugEnv) {
|
|
||||||
createDebug.enable(debugEnv)
|
|
||||||
} else {
|
|
||||||
// 开发环境默认启用所有 msr: 相关的调试
|
|
||||||
createDebug.enable('msr:*')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出 createDebug 以便其他地方创建自定义实例
|
|
||||||
export default createDebug
|
|
|
@ -1,25 +1,17 @@
|
||||||
import artistsOrganize from './artistsOrganize'
|
import artistsOrganize from "./artistsOrganize"
|
||||||
import { audioVisualizer } from './audioVisualizer'
|
import { audioVisualizer } from "./audioVisualizer"
|
||||||
import cicdInfo from './cicdInfo'
|
import cicdInfo from "./cicdInfo"
|
||||||
import {
|
import { checkAndRefreshSongResource, checkAndRefreshMultipleSongs } from "./songResourceChecker"
|
||||||
checkAndRefreshSongResource,
|
import { isSafari, isMobileSafari, supportsWebAudioVisualization, getBrowserInfo } from "./browserDetection"
|
||||||
checkAndRefreshMultipleSongs,
|
|
||||||
} from './songResourceChecker'
|
|
||||||
import {
|
|
||||||
isSafari,
|
|
||||||
isMobileSafari,
|
|
||||||
supportsWebAudioVisualization,
|
|
||||||
getBrowserInfo,
|
|
||||||
} from './browserDetection'
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
artistsOrganize,
|
artistsOrganize,
|
||||||
audioVisualizer,
|
audioVisualizer,
|
||||||
cicdInfo,
|
cicdInfo,
|
||||||
checkAndRefreshSongResource,
|
checkAndRefreshSongResource,
|
||||||
checkAndRefreshMultipleSongs,
|
checkAndRefreshMultipleSongs,
|
||||||
isSafari,
|
isSafari,
|
||||||
isMobileSafari,
|
isMobileSafari,
|
||||||
supportsWebAudioVisualization,
|
supportsWebAudioVisualization,
|
||||||
getBrowserInfo,
|
getBrowserInfo
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import apis from '../apis'
|
import apis from '../apis'
|
||||||
import { debugResource } from './debug'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查歌曲资源 URL 是否可用,如果不可用则刷新
|
* 检查歌曲资源 URL 是否可用,如果不可用则刷新
|
||||||
|
@ -9,51 +8,51 @@ import { debugResource } from './debug'
|
||||||
* @returns 更新后的歌曲对象(如果需要更新)或原始歌曲对象
|
* @returns 更新后的歌曲对象(如果需要更新)或原始歌曲对象
|
||||||
*/
|
*/
|
||||||
export const checkAndRefreshSongResource = async (
|
export const checkAndRefreshSongResource = async (
|
||||||
song: Song,
|
song: Song,
|
||||||
updateCallback?: (updatedSong: Song) => void,
|
updateCallback?: (updatedSong: Song) => void
|
||||||
): Promise<Song> => {
|
): Promise<Song> => {
|
||||||
if (!song.sourceUrl) {
|
if (!song.sourceUrl) {
|
||||||
debugResource('歌曲没有 sourceUrl', song.name)
|
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
|
||||||
return song
|
return song
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查资源是否可用
|
// 检查资源是否可用
|
||||||
await axios.head(song.sourceUrl, {
|
await axios.head(song.sourceUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
Pragma: 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
Expires: '0',
|
'Expires': '0'
|
||||||
},
|
},
|
||||||
params: {
|
params: {
|
||||||
_t: Date.now(), // 添加时间戳参数避免缓存
|
_t: Date.now() // 添加时间戳参数避免缓存
|
||||||
},
|
},
|
||||||
timeout: 5000, // 5秒超时
|
timeout: 5000 // 5秒超时
|
||||||
})
|
})
|
||||||
|
|
||||||
// 资源可用,返回原始歌曲
|
// 资源可用,返回原始歌曲
|
||||||
debugResource('资源可用', song.name)
|
console.log('[ResourceChecker] 资源可用:', song.name)
|
||||||
return song
|
return song
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 资源不可用,刷新歌曲信息
|
// 资源不可用,刷新歌曲信息
|
||||||
debugResource('资源不可用,正在刷新', song.name, error)
|
console.log('[ResourceChecker] 资源不可用,正在刷新:', song.name, error)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedSong = await apis.getSong(song.cid)
|
const updatedSong = await apis.getSong(song.cid)
|
||||||
debugResource('歌曲信息已刷新', updatedSong.name)
|
console.log('[ResourceChecker] 歌曲信息已刷新:', updatedSong.name)
|
||||||
|
|
||||||
// 调用更新回调(如果提供)
|
// 调用更新回调(如果提供)
|
||||||
if (updateCallback) {
|
if (updateCallback) {
|
||||||
updateCallback(updatedSong)
|
updateCallback(updatedSong)
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedSong
|
return updatedSong
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
debugResource('刷新歌曲信息失败', refreshError)
|
console.error('[ResourceChecker] 刷新歌曲信息失败:', refreshError)
|
||||||
// 刷新失败,返回原始歌曲
|
// 刷新失败,返回原始歌曲
|
||||||
return song
|
return song
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -63,19 +62,19 @@ export const checkAndRefreshSongResource = async (
|
||||||
* @returns 更新后的歌曲数组
|
* @returns 更新后的歌曲数组
|
||||||
*/
|
*/
|
||||||
export const checkAndRefreshMultipleSongs = async (
|
export const checkAndRefreshMultipleSongs = async (
|
||||||
songs: Song[],
|
songs: Song[],
|
||||||
updateCallback?: (updatedSong: Song, originalIndex: number) => void,
|
updateCallback?: (updatedSong: Song, originalIndex: number) => void
|
||||||
): Promise<Song[]> => {
|
): Promise<Song[]> => {
|
||||||
const results: Song[] = []
|
const results: Song[] = []
|
||||||
|
|
||||||
for (let i = 0; i < songs.length; i++) {
|
for (let i = 0; i < songs.length; i++) {
|
||||||
const originalSong = songs[i]
|
const originalSong = songs[i]
|
||||||
const updatedSong = await checkAndRefreshSongResource(
|
const updatedSong = await checkAndRefreshSongResource(
|
||||||
originalSong,
|
originalSong,
|
||||||
updateCallback ? (updated) => updateCallback(updated, i) : undefined,
|
updateCallback ? (updated) => updateCallback(updated, i) : undefined
|
||||||
)
|
)
|
||||||
results.push(updatedSong)
|
results.push(updatedSong)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
|
@ -1,298 +0,0 @@
|
||||||
class SimpleAudioPlayer {
|
|
||||||
context: AudioContext
|
|
||||||
currentSource: AudioBufferSourceNode | null
|
|
||||||
audioBuffer: AudioBuffer | null
|
|
||||||
playing: boolean
|
|
||||||
startTime: number
|
|
||||||
pauseTime: number
|
|
||||||
duration: number
|
|
||||||
dummyAudio: HTMLAudioElement
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.context = new window.AudioContext()
|
|
||||||
this.currentSource = null
|
|
||||||
this.audioBuffer = null
|
|
||||||
this.playing = false
|
|
||||||
this.startTime = 0
|
|
||||||
this.pauseTime = 0
|
|
||||||
this.duration = 0
|
|
||||||
|
|
||||||
// 创建一个隐藏的 HTML Audio 元素来帮助同步媒体会话状态
|
|
||||||
this.dummyAudio = new Audio()
|
|
||||||
this.dummyAudio.style.display = 'none'
|
|
||||||
this.dummyAudio.loop = true
|
|
||||||
this.dummyAudio.volume = 0.001 // 极小音量
|
|
||||||
// 使用一个很短的静音音频文件,或者生成一个
|
|
||||||
this.createSilentAudioBlob()
|
|
||||||
|
|
||||||
document.body.appendChild(this.dummyAudio)
|
|
||||||
|
|
||||||
this.initMediaSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
createSilentAudioBlob() {
|
|
||||||
// 创建一个1秒的静音WAV文件
|
|
||||||
const sampleRate = 44100
|
|
||||||
const channels = 1
|
|
||||||
const length = sampleRate * 1 // 1秒
|
|
||||||
|
|
||||||
const arrayBuffer = new ArrayBuffer(44 + length * 2)
|
|
||||||
const view = new DataView(arrayBuffer)
|
|
||||||
|
|
||||||
// WAV 文件头
|
|
||||||
const writeString = (offset: number, string: string) => {
|
|
||||||
for (let i = 0; i < string.length; i++) {
|
|
||||||
view.setUint8(offset + i, string.charCodeAt(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeString(0, 'RIFF')
|
|
||||||
view.setUint32(4, 36 + length * 2, true)
|
|
||||||
writeString(8, 'WAVE')
|
|
||||||
writeString(12, 'fmt ')
|
|
||||||
view.setUint32(16, 16, true)
|
|
||||||
view.setUint16(20, 1, true)
|
|
||||||
view.setUint16(22, channels, true)
|
|
||||||
view.setUint32(24, sampleRate, true)
|
|
||||||
view.setUint32(28, sampleRate * 2, true)
|
|
||||||
view.setUint16(32, 2, true)
|
|
||||||
view.setUint16(34, 16, true)
|
|
||||||
writeString(36, 'data')
|
|
||||||
view.setUint32(40, length * 2, true)
|
|
||||||
|
|
||||||
// 静音数据(全零)
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
view.setInt16(44 + i * 2, 0, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([arrayBuffer], { type: 'audio/wav' })
|
|
||||||
this.dummyAudio.src = URL.createObjectURL(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
initMediaSession() {
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => {
|
|
||||||
console.log('Media session: play requested')
|
|
||||||
this.play()
|
|
||||||
})
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => {
|
|
||||||
console.log('Media session: pause requested')
|
|
||||||
this.pause()
|
|
||||||
})
|
|
||||||
navigator.mediaSession.setActionHandler('stop', () => {
|
|
||||||
console.log('Media session: stop requested')
|
|
||||||
this.stop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadResource() {
|
|
||||||
try {
|
|
||||||
// 如果已经加载过,直接播放
|
|
||||||
if (this.audioBuffer) {
|
|
||||||
this.play()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载音频
|
|
||||||
const response = await fetch(
|
|
||||||
'https://s3-us-west-2.amazonaws.com/s.cdpn.io/858/outfoxing.mp3'
|
|
||||||
)
|
|
||||||
const arrayBuffer = await response.arrayBuffer()
|
|
||||||
this.audioBuffer = await this.context.decodeAudioData(arrayBuffer)
|
|
||||||
this.duration = this.audioBuffer.duration
|
|
||||||
|
|
||||||
// 设置媒体元数据
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: 'Outfoxing the Fox',
|
|
||||||
artist: 'Kevin MacLeod',
|
|
||||||
album: 'YouTube Audio Library',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始播放
|
|
||||||
this.play()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('播放失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async play() {
|
|
||||||
if (!this.audioBuffer) {
|
|
||||||
this.loadResource()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.playing) {
|
|
||||||
console.log('Already playing, ignoring play request')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Starting playback from position:', this.pauseTime)
|
|
||||||
|
|
||||||
// 恢复 AudioContext(如果被暂停)
|
|
||||||
if (this.context.state === 'suspended') {
|
|
||||||
await this.context.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始播放隐藏的 audio 元素
|
|
||||||
try {
|
|
||||||
await this.dummyAudio.play()
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Dummy audio play failed (expected):', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新的源节点
|
|
||||||
this.currentSource = this.context.createBufferSource()
|
|
||||||
this.currentSource.buffer = this.audioBuffer
|
|
||||||
this.currentSource.connect(this.context.destination)
|
|
||||||
|
|
||||||
// 从暂停位置开始播放
|
|
||||||
const offset = this.pauseTime
|
|
||||||
this.currentSource.start(0, offset)
|
|
||||||
|
|
||||||
this.startTime = this.context.currentTime - offset
|
|
||||||
this.playing = true
|
|
||||||
|
|
||||||
// 播放结束处理 - 只在自然结束时触发
|
|
||||||
this.currentSource.onended = () => {
|
|
||||||
console.log('Audio naturally ended')
|
|
||||||
// 检查是否真的播放到了结尾
|
|
||||||
const currentTime = this.getCurrentTime()
|
|
||||||
if (currentTime >= this.duration - 0.1) { // 允许小误差
|
|
||||||
console.log('Natural end of track')
|
|
||||||
this.stop()
|
|
||||||
} else {
|
|
||||||
console.log('Audio ended prematurely (likely paused), current time:', currentTime)
|
|
||||||
// 这是由于暂停导致的结束,不做任何处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新媒体会话状态
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
pause() {
|
|
||||||
console.log('Pause requested, current state - playing:', this.playing, 'hasSource:', !!this.currentSource)
|
|
||||||
|
|
||||||
// 暂停隐藏的 audio 元素
|
|
||||||
this.dummyAudio.pause()
|
|
||||||
|
|
||||||
if (!this.playing) {
|
|
||||||
console.log('Already paused, but updating media session state')
|
|
||||||
// 即使已经暂停,也要确保媒体会话状态正确
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.currentSource) {
|
|
||||||
console.log('No current source, but updating media session state')
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Pausing playback at position:', this.getCurrentTime())
|
|
||||||
|
|
||||||
// 计算当前播放位置
|
|
||||||
this.pauseTime = this.getCurrentTime()
|
|
||||||
|
|
||||||
// 移除 onended 事件处理器,避免干扰
|
|
||||||
this.currentSource.onended = null
|
|
||||||
|
|
||||||
// 停止当前源
|
|
||||||
this.currentSource.stop()
|
|
||||||
this.currentSource = null
|
|
||||||
this.playing = false
|
|
||||||
|
|
||||||
// 更新媒体会话状态
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
console.log('Stopping playback')
|
|
||||||
|
|
||||||
// 停止隐藏的 audio 元素
|
|
||||||
this.dummyAudio.pause()
|
|
||||||
this.dummyAudio.currentTime = 0
|
|
||||||
|
|
||||||
if (this.currentSource) {
|
|
||||||
this.currentSource.stop()
|
|
||||||
this.currentSource = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this.playing = false
|
|
||||||
this.pauseTime = 0
|
|
||||||
this.startTime = 0
|
|
||||||
|
|
||||||
// 更新媒体会话状态
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePlay() {
|
|
||||||
if (this.playing) {
|
|
||||||
this.pause()
|
|
||||||
} else {
|
|
||||||
this.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentTime(): number {
|
|
||||||
if (this.playing && this.currentSource) {
|
|
||||||
return Math.min(this.context.currentTime - this.startTime, this.duration)
|
|
||||||
}
|
|
||||||
return this.pauseTime
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMediaSessionState() {
|
|
||||||
if ('mediaSession' in navigator) {
|
|
||||||
let state = 'none'
|
|
||||||
if (this.playing) {
|
|
||||||
state = 'playing'
|
|
||||||
} else if (this.audioBuffer) {
|
|
||||||
// 只要有音频缓冲区就应该是暂停状态
|
|
||||||
state = 'paused'
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Updating media session state to:', state, '(playing:', this.playing, ', hasBuffer:', !!this.audioBuffer, ')')
|
|
||||||
|
|
||||||
// 强制设置状态
|
|
||||||
try {
|
|
||||||
navigator.mediaSession.playbackState = state as any
|
|
||||||
|
|
||||||
// 更新位置信息
|
|
||||||
if ('setPositionState' in navigator.mediaSession && this.duration > 0) {
|
|
||||||
navigator.mediaSession.setPositionState({
|
|
||||||
duration: this.duration,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
position: this.getCurrentTime()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating media session:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定期更新播放位置
|
|
||||||
startPositionUpdates() {
|
|
||||||
setInterval(() => {
|
|
||||||
if (this.audioBuffer) {
|
|
||||||
this.updateMediaSessionState()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清理资源
|
|
||||||
destroy() {
|
|
||||||
this.stop()
|
|
||||||
if (this.dummyAudio) {
|
|
||||||
document.body.removeChild(this.dummyAudio)
|
|
||||||
}
|
|
||||||
if (this.context.state !== 'closed') {
|
|
||||||
this.context.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SimpleWebAudioPlayer
|
|
58
src/vite-env.d.ts
vendored
58
src/vite-env.d.ts
vendored
|
@ -5,26 +5,26 @@ type SongList = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Song = {
|
type Song = {
|
||||||
cid: string
|
cid: string
|
||||||
name: string
|
name: string
|
||||||
albumCid?: string
|
albumCid?: string
|
||||||
mvUrl?: string | null
|
sourceUrl?: string
|
||||||
mvCoverUrl?: string | null
|
lyricUrl?: string | null
|
||||||
sourceUrl?: string | null
|
mvUrl?: string | null
|
||||||
lyricUrl?: string | null
|
mvCoverUrl?: string | null
|
||||||
artistes?: string[]
|
artistes?: string[]
|
||||||
artists?: string[]
|
artists?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Album = {
|
type Album = {
|
||||||
cid: string
|
cid: string
|
||||||
name: string
|
name: string
|
||||||
intro?: string
|
intro?: string
|
||||||
belong?: string
|
belong?: string
|
||||||
coverUrl: string
|
coverUrl: string
|
||||||
coverDeUrl?: string
|
coverDeUrl?: string
|
||||||
artistes: string[]
|
artistes: string[]
|
||||||
songs?: Song[]
|
songs?: Song[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumList = Album[]
|
type AlbumList = Album[]
|
||||||
|
@ -36,22 +36,20 @@ interface ApiResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
song: Song
|
song: Song
|
||||||
album?: Album
|
album?: Album
|
||||||
sourceUrl?: string
|
|
||||||
lyricUrl?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LyricsLine {
|
interface LyricsLine {
|
||||||
type: 'lyric'
|
type: 'lyric'
|
||||||
time: number
|
time: number
|
||||||
text: string
|
text: string
|
||||||
originalTime: string
|
originalTime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GapLine {
|
interface GapLine {
|
||||||
type: 'gap'
|
type: 'gap'
|
||||||
time: number
|
time: number
|
||||||
originalTime: string
|
originalTime: string
|
||||||
duration?: number // 添加间隔持续时间
|
duration?: number // 添加间隔持续时间
|
||||||
}
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import path from 'node:path'
|
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
@ -26,9 +26,9 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue
Block a user