Compare commits

..

1 Commits

Author SHA1 Message Date
1bd0073e24 Merge pull request '0.0.7' (#11) from dev into main
Some checks failed
构建扩展程序 / 构建 Chrome 扩展程序 (push) Successful in 58s
构建扩展程序 / 构建 Safari 扩展程序 (push) Has been cancelled
构建扩展程序 / 构建 Firefox 附加组件 (push) Successful in 54s
构建扩展程序 / 发布至 Chrome 应用商店 (push) Successful in 36s
构建扩展程序 / 发布至 Edge 附加组件商店 (push) Successful in 1m9s
构建扩展程序 / 发布至 Firefox 附加组件库 (push) Successful in 6m21s
Reviewed-on: #11
2025-06-25 03:25:25 +00:00
43 changed files with 1908 additions and 2752 deletions

View File

@ -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
View File

@ -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",

View File

@ -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"
} }
} }

View File

@ -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') })
}) })
} }

View File

@ -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"
} }
} }

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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>

View File

@ -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
}, }
} }

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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() {
// 1WAV
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>

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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>

View File

@ -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()

View File

@ -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>

View File

@ -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>

View File

@ -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
} }
}) })

View File

@ -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,
} }
}) })

View File

@ -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,
}
})

View File

@ -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
} }
}) })

View File

@ -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
} }
}) })

View File

@ -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;
} }

View File

@ -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(' / ')
}

View File

@ -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,
}
}

View File

@ -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()
} }
} }

View File

@ -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) ?? '未知',
} }

View File

@ -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

View File

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

View File

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

View File

@ -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
View File

@ -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 // 添加间隔持续时间
} }

View File

@ -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"),
}, },
}, }
}) })