Compare commits

..

4 Commits

38 changed files with 1824 additions and 2173 deletions

6
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "msr-mod",
"version": "0.0.0",
"dependencies": {
"@astrian/music-surge-revolution": "^0.0.0-20250831052313",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"gsap": "^3.13.0",
@ -42,6 +43,11 @@
"node": ">=6.0.0"
}
},
"node_modules/@astrian/music-surge-revolution": {
"version": "0.0.0-20250903052637",
"resolved": "https://registry.npmjs.org/@astrian/music-surge-revolution/-/music-surge-revolution-0.0.0-20250903052637.tgz",
"integrity": "sha512-P/cuDEseY1Q/UU5NAcbi53vYGEsC/mlM6If7+gjXqayMiOrTdmHTPypp1A0CrsodAsR0NtUohbeaZHCHgGAd/A=="
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",

View File

@ -20,6 +20,7 @@
"prebuild:safari": "node scripts/prebuild-safari.js"
},
"dependencies": {
"@astrian/music-surge-revolution": "^0.0.0-20250831052313",
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"gsap": "^3.13.0",

View File

@ -1,7 +1,7 @@
console.log("aaaa")
console.log('aaaa')
// 兼容 Chrome 和 Firefox
const browserAPI = typeof browser !== 'undefined' ? browser : chrome;
const browserAPI = typeof browser !== 'undefined' ? browser : chrome
browserAPI.webRequest.onBeforeRequest.addListener(
async (details) => {
@ -16,12 +16,18 @@ browserAPI.webRequest.onBeforeRequest.addListener(
console.log('recived request for fontset api, redirecting to index.html')
const pref = await browserAPI.storage.sync.get('preferences')
if (pref === undefined || pref.preferences === undefined || pref.preferences.autoRedirect === undefined || pref.preferences.autoRedirect === true) {
const isChrome = typeof browserAPI.runtime.getBrowserInfo === 'undefined';
if (
pref === undefined ||
pref.preferences === undefined ||
pref.preferences.autoRedirect === undefined ||
pref.preferences.autoRedirect === true
) {
const isChrome = typeof browserAPI.runtime.getBrowserInfo === 'undefined'
if (isChrome) {
if (
details.url === 'https://monster-siren.hypergryph.com/manifest.json' &&
details.url ===
'https://monster-siren.hypergryph.com/manifest.json' &&
details.type === 'other' &&
details.frameId === 0
) {
@ -32,15 +38,22 @@ browserAPI.webRequest.onBeforeRequest.addListener(
}
} else {
// Firefox: 直接在当前标签页导航
browserAPI.tabs.update(details.tabId, { url: browserAPI.runtime.getURL('index.html') })
browserAPI.tabs.update(details.tabId, {
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
const actionAPI = browserAPI.action || browserAPI.browserAction;
const actionAPI = browserAPI.action || browserAPI.browserAction
if (actionAPI) {
actionAPI.onClicked.addListener(() => {
browserAPI.tabs.create({ url: browserAPI.runtime.getURL('index.html') })

View File

@ -5,12 +5,8 @@
"description": "塞壬唱片Monster Siren Records官网的替代前端。",
"content_scripts": [
{
"matches": [
"https://monster-siren.hypergryph.com/"
],
"js": [
"content.js"
],
"matches": ["https://monster-siren.hypergryph.com/"],
"js": ["content.js"],
"run_at": "document_end"
}
],
@ -36,11 +32,7 @@
"background": {
"service_worker": "background.js"
},
"permissions": [
"tabs",
"webRequest",
"storage"
],
"permissions": ["tabs", "webRequest", "storage"],
"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;",
"sandbox": "sandbox"

View File

@ -1,61 +1,65 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 处理 manifest.json
function processManifest() {
const manifestPath = path.join(__dirname, '../public/manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestPath = path.join(__dirname, '../public/manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
permission => !permission.includes('localhost')
);
}
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
(permission) => !permission.includes('localhost'),
)
}
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim();
}
if (
manifest.content_security_policy &&
manifest.content_security_policy.extension_pages
) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages =
manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim()
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('✅ Manifest.json processed');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
console.log('✅ Manifest.json processed')
}
// 处理 index.html
function processIndexHtml() {
const indexPath = path.join(__dirname, '../index.html');
let content = fs.readFileSync(indexPath, 'utf8');
const indexPath = path.join(__dirname, '../index.html')
let content = fs.readFileSync(indexPath, 'utf8')
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"'
);
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"',
)
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '');
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '')
fs.writeFileSync(indexPath, content);
console.log('✅ Index.html processed');
fs.writeFileSync(indexPath, content)
console.log('✅ Index.html processed')
}
// 执行处理
try {
processManifest();
processIndexHtml();
console.log('🎉 Build preparation completed!');
processManifest()
processIndexHtml()
console.log('🎉 Build preparation completed!')
} catch (error) {
console.error('❌ Error during build preparation:', error);
process.exit(1);
console.error('❌ Error during build preparation:', error)
process.exit(1)
}

View File

@ -1,80 +1,87 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 处理 manifest.json
function processManifest() {
const manifestPath = path.join(__dirname, '../public/manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestPath = path.join(__dirname, '../public/manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
permission => !permission.includes('localhost')
);
}
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
(permission) => !permission.includes('localhost'),
)
}
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim();
}
if (
manifest.content_security_policy &&
manifest.content_security_policy.extension_pages
) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages =
manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim()
}
// 移除 CSP 中的 sandbox 配置Firefox 不支持)
if (manifest.content_security_policy && manifest.content_security_policy.sandbox) {
delete manifest.content_security_policy.sandbox;
}
// 移除 CSP 中的 sandbox 配置Firefox 不支持)
if (
manifest.content_security_policy &&
manifest.content_security_policy.sandbox
) {
delete manifest.content_security_policy.sandbox
}
// 移除 background.service_worker替换为 background.scripts
if (manifest.background && manifest.background.service_worker) {
manifest.background.scripts = [manifest.background.service_worker];
delete manifest.background.service_worker;
}
// 移除 background.service_worker替换为 background.scripts
if (manifest.background && manifest.background.service_worker) {
manifest.background.scripts = [manifest.background.service_worker]
delete manifest.background.service_worker
}
// 添加 firefox 特有配置
manifest.browser_specific_settings = {
gecko: {
id: 'msr-mod@firefox-addon.astrian.moe',
strict_min_version: '115.0',
}
};
// 添加 firefox 特有配置
manifest.browser_specific_settings = {
gecko: {
id: 'msr-mod@firefox-addon.astrian.moe',
strict_min_version: '115.0',
},
}
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
console.log('✅ Manifest.json processed');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
console.log('✅ Manifest.json processed')
}
// 处理 index.html
function processIndexHtml() {
const indexPath = path.join(__dirname, '../index.html');
let content = fs.readFileSync(indexPath, 'utf8');
const indexPath = path.join(__dirname, '../index.html')
let content = fs.readFileSync(indexPath, 'utf8')
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"'
);
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"',
)
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '');
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '')
fs.writeFileSync(indexPath, content);
console.log('✅ Index.html processed');
fs.writeFileSync(indexPath, content)
console.log('✅ Index.html processed')
}
// 执行处理
try {
processManifest();
processIndexHtml();
console.log('🎉 Build preparation completed!');
processManifest()
processIndexHtml()
console.log('🎉 Build preparation completed!')
} catch (error) {
console.error('❌ Error during build preparation:', error);
process.exit(1);
console.error('❌ Error during build preparation:', error)
process.exit(1)
}

View File

@ -1,55 +1,59 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 处理 manifest.json for Safari
function processManifest() {
const manifestPath = path.join(__dirname, '../public/manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestPath = path.join(__dirname, '../public/manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
permission => !permission.includes('localhost')
);
}
// 移除本地调试相关的配置
if (manifest.host_permissions) {
manifest.host_permissions = manifest.host_permissions.filter(
(permission) => !permission.includes('localhost'),
)
}
if (manifest.content_security_policy && manifest.content_security_policy.extension_pages) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages = manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim();
}
if (
manifest.content_security_policy &&
manifest.content_security_policy.extension_pages
) {
// 移除 CSP 中的本地开发相关配置
manifest.content_security_policy.extension_pages =
manifest.content_security_policy.extension_pages
.replace(/script-src 'self' http:\/\/localhost:5173;\s*/g, '')
.replace(/\s*http:\/\/localhost:5173\s*/g, ' ')
.replace(/\s*ws:\/\/localhost:5173\s*/g, ' ')
.replace(/;\s+/g, '; ') // 标准化分号后的空格
.replace(/\s+/g, ' ') // 合并多个空格为一个
.trim()
}
// Safari 特殊处理:添加 appShell.html 到 content scripts 匹配
if (manifest.content_scripts && manifest.content_scripts[0]) {
// 添加 appShell.html 的匹配规则
const existingMatches = manifest.content_scripts[0].matches;
if (!existingMatches.includes("https://monster-siren.hypergryph.com/")) {
existingMatches.push("https://monster-siren.hypergryph.com/");
}
}
// Safari 特殊处理:添加 appShell.html 到 content scripts 匹配
if (manifest.content_scripts && manifest.content_scripts[0]) {
// 添加 appShell.html 的匹配规则
const existingMatches = manifest.content_scripts[0].matches
if (!existingMatches.includes('https://monster-siren.hypergryph.com/')) {
existingMatches.push('https://monster-siren.hypergryph.com/')
}
}
// Safari 特殊处理:使用 background.page 而不是 service_worker
if (manifest.background && manifest.background.service_worker) {
// Safari 扩展在 Manifest V3 中必须使用 persistent: false
// 但为了调试,我们暂时设为 true 来确保页面加载
manifest.background = {
page: "background.html",
persistent: true
};
}
// Safari 特殊处理:使用 background.page 而不是 service_worker
if (manifest.background && manifest.background.service_worker) {
// Safari 扩展在 Manifest V3 中必须使用 persistent: false
// 但为了调试,我们暂时设为 true 来确保页面加载
manifest.background = {
page: 'background.html',
persistent: true,
}
}
// 创建 background.html 文件用于 Safari
const backgroundHtmlPath = path.join(__dirname, '../public/background.html');
const backgroundHtmlContent = `<!DOCTYPE html>
// 创建 background.html 文件用于 Safari
const backgroundHtmlPath = path.join(__dirname, '../public/background.html')
const backgroundHtmlContent = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@ -102,19 +106,19 @@ function processManifest() {
log('=== After background.js script tag ===');
</script>
</body>
</html>`;
fs.writeFileSync(backgroundHtmlPath, backgroundHtmlContent);
</html>`
fs.writeFileSync(backgroundHtmlPath, backgroundHtmlContent)
// 创建 Safari 兼容的 background.js
const backgroundJsPath = path.join(__dirname, '../public/background.js');
let backgroundJsContent = fs.readFileSync(backgroundJsPath, 'utf8');
// 创建 Safari 兼容的 background.js
const backgroundJsPath = path.join(__dirname, '../public/background.js')
let backgroundJsContent = fs.readFileSync(backgroundJsPath, 'utf8')
// 检查是否已经添加过 Safari 代码,避免重复
if (backgroundJsContent.includes('=== Safari background.js starting ===')) {
console.log('Safari background.js already processed, skipping...');
} else {
// 在开头添加 Safari 调试信息(只添加一次)
const safariDebugCode = `
// 检查是否已经添加过 Safari 代码,避免重复
if (backgroundJsContent.includes('=== Safari background.js starting ===')) {
console.log('Safari background.js already processed, skipping...')
} else {
// 在开头添加 Safari 调试信息(只添加一次)
const safariDebugCode = `
console.log("=== Safari background.js starting ===");
console.log("Available APIs:", {
chrome: typeof chrome,
@ -168,40 +172,42 @@ 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 的重定向 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/')"
);
// 替换 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 = 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');
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');
// 创建 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 = `
// 检查是否已经处理过 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
console.log('MSR Mod content script loaded on:', window.location.href);
@ -307,53 +313,53 @@ async function main() {
main().catch(error => {
console.error('Error in main function:', error);
});
`;
`
fs.writeFileSync(contentJsPath, contentJsContent);
}
console.log('✅ Safari-compatible content.js created');
fs.writeFileSync(contentJsPath, contentJsContent)
}
console.log('✅ Safari-compatible content.js created')
// Safari 可能需要额外的权限
if (!manifest.permissions.includes('activeTab')) {
manifest.permissions.push('activeTab');
}
// Safari 可能需要额外的权限
if (!manifest.permissions.includes('activeTab')) {
manifest.permissions.push('activeTab')
}
// 添加 Safari 特有配置
manifest.browser_specific_settings = {
safari: {
minimum_version: "14.0"
}
};
// 添加 Safari 特有配置
manifest.browser_specific_settings = {
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');
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
console.log('✅ Safari Manifest.json processed')
console.log('✅ Background.html created for Safari')
}
// 处理 index.html
function processIndexHtml() {
const indexPath = path.join(__dirname, '../index.html');
let content = fs.readFileSync(indexPath, 'utf8');
const indexPath = path.join(__dirname, '../index.html')
let content = fs.readFileSync(indexPath, 'utf8')
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"'
);
// 替换脚本地址
content = content.replace(
/src="[^"]*\/src\/main\.ts"/g,
'src="./src/main.ts"',
)
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '');
// 移除 crossorigin 属性
content = content.replace(/\s+crossorigin/g, '')
fs.writeFileSync(indexPath, content);
console.log('✅ Index.html processed for Safari');
fs.writeFileSync(indexPath, content)
console.log('✅ Index.html processed for Safari')
}
// 执行处理
try {
processManifest();
processIndexHtml();
console.log('🎉 Safari build preparation completed!');
processManifest()
processIndexHtml()
console.log('🎉 Safari build preparation completed!')
} catch (error) {
console.error('❌ Error during Safari build preparation:', error);
process.exit(1);
console.error('❌ Error during Safari build preparation:', error)
process.exit(1)
}

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import Player from './components/Player.vue'
import PreferencePanel from './components/PreferencePanel.vue'
import { ref } from 'vue'
@ -10,16 +9,19 @@ import CorgIcon from './assets/icons/corg.vue'
import { watch } from 'vue'
import UpdatePopup from './components/UpdatePopup.vue'
import MiniPlayer from './components/MiniPlayer.vue'
const presentPreferencePanel = ref(false)
const route = useRoute()
const router = useRouter()
watch(() => presentPreferencePanel, (value) => {
console.log(value)
})
watch(
() => presentPreferencePanel,
(value) => {
console.log(value)
},
)
</script>
<template>
@ -72,8 +74,7 @@ watch(() => presentPreferencePanel, (value) => {
@click="presentPreferencePanel = true">
<CorgIcon :size="4" />
</button>
<Player />
<MiniPlayer />
</div>
</div>
</div>

View File

@ -9,33 +9,41 @@ export default {
const songs: {
data: ApiResponse
} = await msrInstance.get('songs')
if (songs.data.code !== 0) { throw new Error(`Cannot get songs: ${songs.data.msg}`) }
if (songs.data.code !== 0) {
throw new Error(`Cannot get songs: ${songs.data.msg}`)
}
return { songs: songs.data.data as { list: SongList } }
},
async getSong(cid: string) {
const song: {
data: ApiResponse
} = await msrInstance.get(`song/${cid}`)
if (song.data.code!== 0) { throw new Error(`Cannot get song: ${song.data.msg}`) }
if (song.data.code !== 0) {
throw new Error(`Cannot get song: ${song.data.msg}`)
}
return song.data.data as Song
},
async getAlbums() {
const albums: {
data: ApiResponse
} = await msrInstance.get('albums')
if (albums.data.code!== 0) { throw new Error(`Cannot get albums: ${albums.data.msg}`) }
if (albums.data.code !== 0) {
throw new Error(`Cannot get albums: ${albums.data.msg}`)
}
return albums.data.data as AlbumList
},
async getAlbum(cid: string) {
const album: {
data: ApiResponse
} = await msrInstance.get(`album/${cid}/detail`)
if (album.data.code!== 0) { throw new Error(`Cannot get album: ${album.data.msg}`) }
if (album.data.code !== 0) {
throw new Error(`Cannot get album: ${album.data.msg}`)
}
const albumMeta: {
data: ApiResponse
} = await msrInstance.get(`album/${cid}/data`)
let data = album.data.data as Album
data.artistes = (albumMeta.data.data as Album).artistes
return data
}
},
}

View File

@ -8,7 +8,7 @@ import { ref, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import apis from '../apis'
import { artistsOrganize } from '../utils'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { usePlayStore } from '../stores/usePlayStore'
import TrackItem from './TrackItem.vue'
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
@ -17,9 +17,7 @@ const props = defineProps<{
present: boolean
}>()
const emit = defineEmits<{
(e: 'dismiss'): void
}>()
const emit = defineEmits<(e: 'dismiss') => void>()
const album = ref<Album>()
const dialogBackdrop = ref<HTMLElement>()
@ -28,7 +26,9 @@ const closeButton = ref<HTMLElement>()
// Animation functions
const animateIn = async () => {
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) return
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {
return
}
// Set initial states
gsap.set(dialogBackdrop.value, { opacity: 0 })
@ -41,108 +41,141 @@ const animateIn = async () => {
tl.to(dialogBackdrop.value, {
opacity: 1,
duration: 0.3,
ease: "power2.out"
ease: 'power2.out',
})
.to(dialogContent.value, {
y: 0,
opacity: 1,
scale: 1,
duration: 0.4,
ease: "power3.out"
}, "-=0.1")
.to(closeButton.value, {
scale: 1,
rotation: 0,
duration: 0.3,
ease: "back.out(1.7)"
}, "-=0.2")
.to(
dialogContent.value,
{
y: 0,
opacity: 1,
scale: 1,
duration: 0.4,
ease: 'power3.out',
},
'-=0.1',
)
.to(
closeButton.value,
{
scale: 1,
rotation: 0,
duration: 0.3,
ease: 'back.out(1.7)',
},
'-=0.2',
)
}
const animateOut = () => {
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) return
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {
return
}
const tl = gsap.timeline({
onComplete: () => emit('dismiss')
onComplete: () => emit('dismiss'),
})
tl.to(closeButton.value, {
scale: 0,
rotation: 180,
duration: 0.2,
ease: "power2.in"
ease: 'power2.in',
})
.to(dialogContent.value, {
y: 30,
opacity: 0,
scale: 0.95,
duration: 0.3,
ease: "power2.in"
}, "-=0.1")
.to(dialogBackdrop.value, {
opacity: 0,
duration: 0.2,
ease: "power2.in"
}, "-=0.1")
.to(
dialogContent.value,
{
y: 30,
opacity: 0,
scale: 0.95,
duration: 0.3,
ease: 'power2.in',
},
'-=0.1',
)
.to(
dialogBackdrop.value,
{
opacity: 0,
duration: 0.2,
ease: 'power2.in',
},
'-=0.1',
)
}
// biome-ignore lint/correctness/noUnusedVariables: used inside <template>
const handleClose = () => {
animateOut()
}
watch(() => props.present, async (newVal) => {
if (newVal) {
await nextTick()
animateIn()
}
})
watch(() => props.albumCid, async () => {
console.log("AlbumDetailDialog mounted with albumCid:", props.albumCid)
album.value = undefined // Reset album when cid changes
try {
let res = await apis.getAlbum(props.albumCid)
for (const track in res.songs) {
res.songs[parseInt(track)] = await apis.getSong(res.songs[parseInt(track)].cid)
watch(
() => props.present,
async (newVal) => {
if (newVal) {
await nextTick()
animateIn()
}
album.value = res
} catch (error) {
console.error(error)
}
})
},
)
const playQueue = usePlayQueueStore()
watch(
() => props.albumCid,
async () => {
console.log('AlbumDetailDialog mounted with albumCid:', props.albumCid)
album.value = undefined // Reset album when cid changes
try {
const res = await apis.getAlbum(props.albumCid)
for (const track in res.songs) {
res.songs[Number.parseInt(track, 10)] = await apis.getSong(
res.songs[Number.parseInt(track, 10)].cid,
)
}
album.value = res
} catch (error) {
console.error(error)
}
},
)
function playTheAlbum(from: number = 0) {
const playQueue = usePlayStore()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function playTheAlbum(from = 0) {
if (playQueue.queueReplaceLock) {
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
if (
!confirm(
'当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?',
)
) {
return
}
playQueue.queueReplaceLock = false
}
let newPlayQueue = []
const newPlayQueue = []
for (const track of album.value?.songs ?? []) {
console.log(track)
newPlayQueue.push({
song: track,
album: album.value
album: album.value,
})
}
playQueue.list = newPlayQueue
playQueue.currentIndex = from
playQueue.isPlaying = true
playQueue.isBuffering = true
playQueue.replaceQueue(newPlayQueue)
// playQueue.currentIndex = from
playQueue.togglePlay(true)
}
// biome-ignore lint/correctness/noUnusedVariables: used inside <template>
function shuffle() {
playTheAlbum()
playQueue.shuffleCurrent = true
playQueue.playMode.shuffle = false
setTimeout(() => {
playQueue.playMode.shuffle = true
playQueue.isPlaying = true
playQueue.isBuffering = true
}, 100)
// playTheAlbum()
// playQueue.shuffleCurrent = true
// playQueue.playMode.shuffle = false
// setTimeout(() => {
// playQueue.playMode.shuffle = true
// playQueue.isPlaying = true
// playQueue.isBuffering = true
// }, 100)
}
</script>
<template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { usePlayStore } from '../stores/usePlayStore'
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const playQueue = usePlayStore()
</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.metadata?.artwork?.[0].src ?? ''" />
</div>
<div class="text-white">{{playQueue.currentTrack.metadata?.title ?? "未知歌曲"}}</div>
</div>
</div>
</RouterLink>
</template>

View File

@ -5,18 +5,19 @@ import { useFavourites } from '../stores/useFavourites'
import StarSlashIcon from '../assets/icons/starslash.vue'
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const favourites = useFavourites()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const hover = ref(false)
defineProps<{
item: QueueItem
item: InternalQueueItem
index: number
}>()
const emit = defineEmits<{
(e: 'play', index: number): void
}>()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const emit = defineEmits<(e: 'play', index: number) => void>()
</script>
<template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { usePlayStore } from '../stores/usePlayStore'
import { artistsOrganize, supportsWebAudioVisualization } from '../utils'
import XIcon from '../assets/icons/x.vue'
@ -10,18 +10,21 @@ import SoundwaveIcon from '../assets/icons/soundwave.vue'
import { ref } from 'vue'
const props = defineProps<{
queueItem: QueueItem
queueItem: InternalQueueItem
isCurrent: boolean
index: number
}>()
const playQueueStore = usePlayQueueStore()
const playStore = usePlayStore()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const hover = ref(false)
//
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const isAudioVisualizationSupported = supportsWebAudioVisualization()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function moveUp() {
if (props.index === 0) return
@ -58,8 +61,11 @@ function moveUp() {
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function moveDown() {
const listLength = playQueueStore.playMode.shuffle ? playQueueStore.shuffleList.length : playQueueStore.list.length
const listLength = playQueueStore.playMode.shuffle
? playQueueStore.shuffleList.length
: playQueueStore.list.length
if (props.index === listLength - 1) return
playQueueStore.queueReplaceLock = true
@ -95,6 +101,7 @@ function moveDown() {
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function removeItem() {
playQueueStore.queueReplaceLock = true
@ -109,7 +116,10 @@ function removeItem() {
playQueueStore.currentIndex--
} else if (props.index === playQueueStore.currentIndex) {
if (queue.length > 0) {
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1)
playQueueStore.currentIndex = Math.min(
playQueueStore.currentIndex,
queue.length - 1,
)
} else {
playQueueStore.currentIndex = 0
}
@ -140,7 +150,10 @@ function removeItem() {
playQueueStore.currentIndex--
} else if (props.index === playQueueStore.currentIndex) {
if (queue.length > 0) {
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1)
playQueueStore.currentIndex = Math.min(
playQueueStore.currentIndex,
queue.length - 1,
)
} else {
playQueueStore.currentIndex = 0
}
@ -152,8 +165,8 @@ function removeItem() {
<template>
<button class="p-4 w-full rounded-md hover:bg-white/5 first:mt-2 flex gap-2 items-center" @click="() => {
if (isCurrent) { return }
playQueueStore.currentIndex = index
playQueueStore.isPlaying = true
// playStore.currentIndex = index
// playStore.isPlaying = true
}" @mouseenter="hover = true" @mouseleave="hover = false">
<div class="flex gap-2 flex-auto w-0">
<div class="relative w-12 h-12 rounded-md shadow-xl overflow-hidden">
@ -162,7 +175,7 @@ function removeItem() {
v-if="isCurrent">
<!-- 在支持的浏览器上显示可视化否则显示音波图标 -->
<div v-if="isAudioVisualizationSupported" style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]">
<div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer"
<div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playStore.visualizer"
:key="index" :style="{
height: `${Math.max(10, bar)}%`
}" />
@ -189,8 +202,8 @@ function removeItem() {
<button
class="text-white/90 w-4 h-4 hover:scale-110 hover:text-white active:scale-95 active:text-white/85 transition-all"
@click.stop="moveDown"
:disabled="index === (playQueueStore.playMode.shuffle ? playQueueStore.shuffleList.length : playQueueStore.list.length) - 1"
v-if="index !== (playQueueStore.playMode.shuffle ? playQueueStore.shuffleList.length : playQueueStore.list.length) - 1">
:disabled="index === (playStore.playMode.shuffle ? playStore.shuffleList.length : playStore.list.length) - 1"
v-if="index !== (playStore.playMode.shuffle ? playStore.shuffleList.length : playStore.list.length) - 1">
<DownHyphenIcon :size="4" />
</button>

View File

@ -1,535 +0,0 @@
<!-- Player.vue - 添加预加载功能 -->
<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 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 playQueueStore = usePlayQueueStore()
const favourites = useFavourites()
const route = useRoute()
const player = useTemplateRef('playerRef')
// [] store
console.log('[Player] 检查 store 方法:', {
preloadNext: typeof playQueueStore.preloadNext,
getPreloadedAudio: typeof playQueueStore.getPreloadedAudio,
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio,
})
//
const currentTrack = computed(() => {
if (
playQueueStore.playMode.shuffle &&
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(
() => playQueueStore.isPlaying,
(newValue) => {
if (newValue) {
player.value?.play()
setMetadata()
} else {
player.value?.pause()
}
},
)
//
watch(
() => 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
.map((artist) => {
return artist
})
.join(' / ')
}
function setMetadata() {
if ('mediaSession' in navigator) {
const current = currentTrack.value
if (!current) return
navigator.mediaSession.metadata = new MediaMetadata({
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)
navigator.mediaSession.setActionHandler('nexttrack', playNext)
playQueueStore.duration = player.value?.duration ?? 0
playQueueStore.currentTime = player.value?.currentTime ?? 0
}
watch(
() => playQueueStore.updatedCurrentTime,
(newValue) => {
if (newValue === null) {
return
}
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
},
)
}
function playNext() {
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
console.log('at the bottom, pause')
playQueueStore.currentIndex = 0
if (playQueueStore.playMode.repeat === 'all') {
playQueueStore.currentIndex = 0
playQueueStore.isPlaying = true
} else {
player.value?.pause()
playQueueStore.isPlaying = false
}
} else {
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>
<template>
<div>
<audio :src="currentAudioSrc" ref="playerRef" :autoplay="playQueueStore.isPlaying"
v-if="playQueueStore.list.length !== 0" @volumechange="handleVolumeChange" @ended="() => {
if (playQueueStore.playMode.repeat === 'single') { playQueueStore.isPlaying = true }
else { playNext() }
}" @pause="playQueueStore.isPlaying = false" @play="playQueueStore.isPlaying = true" @playing="() => {
console.log('[Player] 音频开始播放事件')
playQueueStore.isBuffering = false
setMetadata()
initializeVolume()
}" @waiting="playQueueStore.isBuffering = true" @loadeddata="() => {
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>
</template>

View File

@ -1,5 +1,4 @@
<script lang="ts" setup>
import XIcon from '../assets/icons/x.vue'
import { usePreferences } from '../stores/usePreferences'
import { computed } from 'vue'

View File

@ -89,7 +89,7 @@
import { onMounted, ref, watch, nextTick, computed, onUnmounted } from 'vue'
import axios from 'axios'
import gsap from 'gsap'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { usePlayStore } from '../stores/usePlayStore'
//
interface LyricsLine {
@ -106,7 +106,7 @@ interface GapLine {
duration?: number
}
const playQueueStore = usePlayQueueStore()
const playStore = usePlayStore()
//
const parsedLyrics = ref<(LyricsLine | GapLine)[]>([])
@ -121,7 +121,7 @@ const lyricsWrapper = ref<HTMLElement>()
const lineRefs = ref<(HTMLElement | null)[]>([])
const controlPanel = ref<HTMLElement>()
const loadingIndicator = ref<HTMLElement>()
const noLyricsIndicator = ref<HTMLElement>()
//const noLyricsIndicator = ref<HTMLElement>()
// GSAP
let scrollTween: gsap.core.Tween | null = null
@ -135,19 +135,29 @@ const props = defineProps<{
//
const scrollIndicatorHeight = computed(() => {
if (parsedLyrics.value.length === 0) return 0
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 5
if (parsedLyrics.value.length === 0) {
return 0
}
return Math.max(10, (100 / parsedLyrics.value.length) * 5) // 5
})
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const scrollIndicatorPosition = computed(() => {
if (parsedLyrics.value.length === 0 || currentLineIndex.value < 0) return 0
if (parsedLyrics.value.length === 0 || currentLineIndex.value < 0) {
return 0
}
const progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
const containerHeight = lyricsContainer.value?.clientHeight || 400
const indicatorTrackHeight = containerHeight / 2 //
return progress * (indicatorTrackHeight - (scrollIndicatorHeight.value / 100 * indicatorTrackHeight))
return (
progress *
(indicatorTrackHeight -
(scrollIndicatorHeight.value / 100) * indicatorTrackHeight)
)
})
//
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function setLineRef(el: HTMLElement | null, index: number) {
if (el) {
lineRefs.value[index] = el
@ -155,15 +165,20 @@ function setLineRef(el: HTMLElement | null, index: number) {
}
//
function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine | GapLine)[] {
if (!lrcText) return [
{
type: 'lyric',
time: 0,
text: '',
originalTime: '[00:00]'
}
]
function parseLyrics(
lrcText: string,
minGapDuration = 5,
): (LyricsLine | GapLine)[] {
if (!lrcText) {
return [
{
type: 'lyric',
time: 0,
text: '',
originalTime: '[00:00]',
},
]
}
const lines = lrcText.split('\n')
const tempParsedLines: (LyricsLine | GapLine)[] = []
@ -172,14 +187,18 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
for (const line of lines) {
const matches = [...line.matchAll(timeRegex)]
if (matches.length === 0) continue
if (matches.length === 0) {
continue
}
const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
for (const match of matches) {
const minutes = parseInt(match[1])
const seconds = parseInt(match[2])
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0
const minutes = Number.parseInt(match[1])
const seconds = Number.parseInt(match[2])
const milliseconds = match[3]
? Number.parseInt(match[3].padEnd(3, '0'))
: 0
const totalSeconds = minutes * 60 + seconds + milliseconds / 1000
@ -188,13 +207,13 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
type: 'lyric',
time: totalSeconds,
text: text,
originalTime: match[0]
originalTime: match[0],
})
} else {
tempParsedLines.push({
type: 'gap',
time: totalSeconds,
originalTime: match[0]
originalTime: match[0],
})
}
}
@ -203,14 +222,20 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
tempParsedLines.sort((a, b) => a.time - b.time)
const finalLines: (LyricsLine | GapLine)[] = []
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[]
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[]
const lyricLines = tempParsedLines.filter(
(line) => line.type === 'lyric',
) 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++) {
const gapLine = gapLines[i]
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time)
const nextLyricLine = lyricLines.find((lyric) => lyric.time > gapLine.time)
if (nextLyricLine) {
const duration = nextLyricLine.time - gapLine.time
@ -229,16 +254,20 @@ function parseLyrics(lrcText: string, minGapDuration: number = 5): (LyricsLine |
type: 'lyric',
time: 0,
text: '',
originalTime: '[00:00]'
originalTime: '[00:00]',
})
return sortedLines
}
//
function findCurrentLineIndex(time: number): number {
if (parsedLyrics.value.length === 0) return -1
if (parsedLyrics.value.length === 0) {
return -1
}
// 0
if (time < parsedLyrics.value[1]?.time) return 0
if (time < parsedLyrics.value[1]?.time) {
return 0
}
let index = 0
for (let i = 1; i < parsedLyrics.value.length; i++) {
if (time >= parsedLyrics.value[i].time) {
@ -252,7 +281,13 @@ function findCurrentLineIndex(time: number): number {
// 使 GSAP
function scrollToLine(lineIndex: number, smooth = true) {
if (!lyricsContainer.value || !lyricsWrapper.value || !lineRefs.value[lineIndex]) return
if (
!lyricsContainer.value ||
!lyricsWrapper.value ||
!lineRefs.value[lineIndex]
) {
return
}
const container = lyricsContainer.value
const wrapper = lyricsWrapper.value
@ -276,10 +311,10 @@ function scrollToLine(lineIndex: number, smooth = true) {
scrollTween = gsap.to(wrapper, {
y: targetY,
duration: 0.8,
ease: "power2.out",
ease: 'power2.out',
onComplete: () => {
scrollTween = null
}
},
})
} else {
gsap.set(wrapper, { y: targetY })
@ -288,7 +323,9 @@ function scrollToLine(lineIndex: number, smooth = true) {
//
function highlightCurrentLine(lineIndex: number) {
if (!lineRefs.value[lineIndex]) return
if (!lineRefs.value[lineIndex]) {
return
}
const lineElement = lineRefs.value[lineIndex]
@ -304,7 +341,7 @@ function highlightCurrentLine(lineIndex: number) {
scale: 1,
opacity: index < lineIndex ? 0.6 : 0.4,
duration: 0.3,
ease: "power2.out"
ease: 'power2.out',
})
}
})
@ -314,18 +351,21 @@ function highlightCurrentLine(lineIndex: number) {
scale: 1.05,
opacity: 1,
duration: 0.2,
ease: "back.out(1.7)",
ease: 'back.out(1.7)',
onComplete: () => {
highlightTween = null
}
},
})
}
//
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function handleWheel(event: WheelEvent) {
event.preventDefault()
if (!lyricsWrapper.value || !lyricsContainer.value) return
if (!lyricsWrapper.value || !lyricsContainer.value) {
return
}
userScrolling.value = true
autoScroll.value = false
@ -334,7 +374,7 @@ function handleWheel(event: WheelEvent) {
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
//
@ -347,7 +387,7 @@ function handleWheel(event: WheelEvent) {
gsap.to(lyricsWrapper.value, {
y: limitedY,
duration: 0.1,
ease: "power2.out"
ease: 'power2.out',
})
if (userScrollTimeout) {
@ -365,6 +405,7 @@ function handleWheel(event: WheelEvent) {
}
//
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function handleLineClick(line: LyricsLine | GapLine, index: number) {
if (line.type === 'lyric') {
console.log('Jump to time:', line.time)
@ -377,15 +418,16 @@ function handleLineClick(line: LyricsLine | GapLine, index: number) {
//
if (lineRefs.value[index]) {
gsap.fromTo(lineRefs.value[index],
gsap.fromTo(
lineRefs.value[index],
{ scale: 1 },
{
scale: 1.1,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
ease: 'power2.inOut',
},
)
}
}
@ -397,15 +439,16 @@ function toggleAutoScroll() {
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[0],
gsap.fromTo(
controlPanel.value.children[0],
{ scale: 1 },
{
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
ease: 'power2.inOut',
},
)
}
@ -418,17 +461,23 @@ function toggleAutoScroll() {
//
function resetScroll() {
if (!lyricsWrapper.value) return
if (!lyricsWrapper.value) {
return
}
//
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (scrollTween) {
scrollTween.kill()
}
if (highlightTween) {
highlightTween.kill()
}
//
gsap.to(lyricsWrapper.value, {
y: 0,
duration: 0.3,
ease: "power2.out"
ease: 'power2.out',
})
autoScroll.value = true
@ -436,15 +485,16 @@ function resetScroll() {
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[1],
gsap.fromTo(
controlPanel.value.children[1],
{ scale: 1 },
{
scale: 0.95,
duration: 0.1,
yoyo: true,
repeat: 1,
ease: "power2.inOut"
}
ease: 'power2.inOut',
},
)
}
@ -456,12 +506,15 @@ function resetScroll() {
}
// gap
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function getGapDotOpacities(line: GapLine) {
// gap
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 = playQueueStore.currentTime
const now = playStore.progress.currentTime
// gap
const start = line.time
//
@ -470,79 +523,91 @@ function getGapDotOpacities(line: GapLine) {
//
const thresholds = [1 / 4, 2 / 4, 3 / 4]
// 0.3 1
return thresholds.map(t => progress >= t ? 1 : progress >= t - 1 / 3 ? 0.6 : 0.3)
return thresholds.map((t) =>
progress >= t ? 1 : progress >= t - 1 / 3 ? 0.6 : 0.3,
)
}
//
watch(() => playQueueStore.currentTime, (time) => {
const newIndex = findCurrentLineIndex(time)
watch(
() => playStore.progress.currentTime,
(time) => {
const newIndex = findCurrentLineIndex(time)
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
currentLineIndex.value = newIndex
if (newIndex !== currentLineIndex.value && newIndex >= 0) {
currentLineIndex.value = newIndex
//
highlightCurrentLine(newIndex)
//
highlightCurrentLine(newIndex)
//
if (autoScroll.value && !userScrolling.value) {
nextTick(() => {
scrollToLine(newIndex, true)
})
//
if (autoScroll.value && !userScrolling.value) {
nextTick(() => {
scrollToLine(newIndex, true)
})
}
}
}
})
},
)
//
watch(() => props.lrcSrc, async (newSrc) => {
console.log('Loading new lyrics from:', newSrc)
//
currentLineIndex.value = -1
lineRefs.value = []
watch(
() => props.lrcSrc,
async (newSrc) => {
console.log('Loading new lyrics from:', newSrc)
//
currentLineIndex.value = -1
lineRefs.value = []
//
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (newSrc) {
loading.value = true
//
if (loadingIndicator.value) {
gsap.fromTo(loadingIndicator.value,
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" }
)
//
if (scrollTween) {
scrollTween.kill()
}
if (highlightTween) {
highlightTween.kill()
}
try {
const response = await axios.get(newSrc)
parsedLyrics.value = parseLyrics(response.data)
console.log('Parsed lyrics:', parsedLyrics.value)
if (newSrc) {
loading.value = true
autoScroll.value = true
userScrolling.value = false
//
if (loadingIndicator.value) {
gsap.fromTo(
loadingIndicator.value,
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.3, ease: 'back.out(1.7)' },
)
}
try {
const response = await axios.get(newSrc)
parsedLyrics.value = parseLyrics(response.data)
console.log('Parsed lyrics:', parsedLyrics.value)
autoScroll.value = true
userScrolling.value = false
//
if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 })
}
} catch (error) {
console.error('Failed to load lyrics:', error)
parsedLyrics.value = []
} finally {
loading.value = false
}
} else {
parsedLyrics.value = []
//
if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 })
}
} catch (error) {
console.error('Failed to load lyrics:', error)
parsedLyrics.value = []
} finally {
loading.value = false
}
} else {
parsedLyrics.value = []
//
if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 })
}
}
}, { immediate: true })
},
{ immediate: true },
)
//
let handleVisibilityChange: (() => void) | null = null
@ -552,16 +617,28 @@ function setupPageFocusHandlers() {
handleVisibilityChange = () => {
if (document.hidden) {
//
if (scrollTween) scrollTween.pause()
if (highlightTween) highlightTween.pause()
if (scrollTween) {
scrollTween.pause()
}
if (highlightTween) {
highlightTween.pause()
}
} else {
//
if (scrollTween && scrollTween.paused()) scrollTween.resume()
if (highlightTween && highlightTween.paused()) highlightTween.resume()
if (scrollTween?.paused()) {
scrollTween.resume()
}
if (highlightTween?.paused()) {
highlightTween.resume()
}
//
nextTick(() => {
if (currentLineIndex.value >= 0 && autoScroll.value && !userScrolling.value) {
if (
currentLineIndex.value >= 0 &&
autoScroll.value &&
!userScrolling.value
) {
scrollToLine(currentLineIndex.value, false) // 使
}
})
@ -578,9 +655,10 @@ onMounted(() => {
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value,
gsap.fromTo(
controlPanel.value,
{ 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 },
)
}
@ -588,15 +666,16 @@ onMounted(() => {
nextTick(() => {
lineRefs.value.forEach((el, index) => {
if (el) {
gsap.fromTo(el,
gsap.fromTo(
el,
{ opacity: 0, y: 30 },
{
opacity: 1,
y: 0,
duration: 0.2,
ease: "power2.out",
delay: index * 0.1
}
ease: 'power2.out',
delay: index * 0.1,
},
)
}
})
@ -605,9 +684,15 @@ onMounted(() => {
//
onUnmounted(() => {
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (userScrollTimeout) clearTimeout(userScrollTimeout)
if (scrollTween) {
scrollTween.kill()
}
if (highlightTween) {
highlightTween.kill()
}
if (userScrollTimeout) {
clearTimeout(userScrollTimeout)
}
//
if (handleVisibilityChange) {
@ -620,7 +705,10 @@ defineExpose({
scrollToLine,
toggleAutoScroll,
resetScroll,
getCurrentLine: () => currentLineIndex.value >= 0 ? parsedLyrics.value[currentLineIndex.value] : null
getCurrentLine: () =>
currentLineIndex.value >= 0
? parsedLyrics.value[currentLineIndex.value]
: null,
})
</script>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { RouterLink } from 'vue-router'
import { RouterLink } from 'vue-router'
</script>
<template>

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { artistsOrganize } from '../utils'
import { ref } from 'vue'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useToast } from 'vue-toast-notification'
import { usePlayStore } from '../stores/usePlayStore'
import { useFavourites } from '../stores/useFavourites'
import QueueAddIcon from '../assets/icons/queueadd.vue'
@ -10,30 +9,24 @@ import StarEmptyIcon from '../assets/icons/starempty.vue'
import StarFilledIcon from '../assets/icons/starfilled.vue'
const props = defineProps<{
album?: Album,
track: Song,
index: number,
playfrom: (index: number) => void,
album?: Album
track: Song
index: number
playfrom: (index: number) => void
}>()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const hover = ref(false)
const playQueueStore = usePlayQueueStore()
const toast = useToast()
const playStore = usePlayStore()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const favourites = useFavourites()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function appendToQueue() {
console.log('aaa')
let queue = playQueueStore.list
queue.push({
playStore.appendItem({
song: props.track,
album: props.album,
} as QueueItem)
playQueueStore.list = queue
playQueueStore.queueReplaceLock = true
toast.success('已添加到播放队列末尾', {
position: 'top-right',
duration: 1000,
})
}
</script>

View File

@ -7,23 +7,20 @@ import 'vue-toast-notification/dist/theme-default.css'
import App from './App.vue'
import HomePage from './pages/Home.vue'
import AlbumDetailView from './pages/AlbumDetail.vue'
import Playroom from './pages/Playroom.vue'
import Library from './pages/Library.vue'
const routes = [
{ path: '/', component: HomePage },
{ path: '/albums/:albumId', component: AlbumDetailView },
{ path: '/playroom', component: Playroom },
{ path: '/library', component: Library }
{ path: '/', component: HomePage },
{ path: '/playroom', component: Playroom },
{ path: '/library', component: Library },
]
const router = createRouter({
history: createWebHashHistory(),
routes
history: createWebHashHistory(),
routes,
})
const pinia = createPinia()
createApp(App).use(router).use(pinia).use(ToastPlugin).mount('#app')

View File

@ -1,107 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import apis from '../apis'
import { useRoute } from 'vue-router'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize } from '../utils'
import TrackItem from '../components/TrackItem.vue'
import PlayIcon from '../assets/icons/play.vue'
import StarEmptyIcon from '../assets/icons/starempty.vue'
import ShuffleIcon from '../assets/icons/shuffle.vue'
const album = ref<Album>()
const route = useRoute()
const albumId = route.params.albumId
const playQueue = usePlayQueueStore()
onMounted(async () => {
try {
let res = await apis.getAlbum(albumId as string)
for (const track in res.songs) {
res.songs[parseInt(track)] = await apis.getSong(res.songs[parseInt(track)].cid)
}
album.value = res
console.log(res)
} catch (error) {
console.log(error)
}
})
function playTheAlbum(from: number = 0) {
if (playQueue.queueReplaceLock) {
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return }
playQueue.queueReplaceLock = false
}
let newPlayQueue = []
for (const track of album.value?.songs ?? []) {
console.log(track)
newPlayQueue.push({
song: track,
album: album.value
})
}
playQueue.playMode.shuffle = false
playQueue.list = newPlayQueue
playQueue.currentIndex = from
playQueue.isPlaying = true
playQueue.isBuffering = true
}
</script>
<template>
<div class="px-4 md:px-8 flex gap-8 flex-col md:flex-row select-none mt-[6.625rem]">
<div class="mx-auto md:mx-0 md:w-72">
<div class="md:sticky md:top-[6.625rem] flex flex-col gap-8">
<div
class="border border-[#5b5b5b] rounded-md overflow-hidden shadow-2xl bg-neutral-800 sticky w-48 mx-auto md:w-72">
<img :src="album?.coverUrl" class="md:w-72 md:h-72 w-48 h-48 object-contain" />
</div>
<div class="flex flex-col gap-2 text-center md:text-left">
<div class="text-white text-2xl font-semibold">{{ album?.name }}</div>
<div class="text-sky-200 text-xl">{{ artistsOrganize(album?.artistes ?? []) }}</div>
<div class="text-white/50 text-sm">{{ album?.intro }}</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col gap-8 mb-2">
<div class="flex justify-between items-center">
<div class="flex gap-2">
<button
class="bg-sky-500/20 hover:bg-sky-500/30 active:bg-sky-600/30 active:shadow-inner border border-[#ffffff39] rounded-full w-56 h-10 text-base text-white flex justify-center items-center gap-2"
@click="playTheAlbum()">
<PlayIcon :size="4" />
<div>播放专辑</div>
</button>
<button
class="text-white w-10 h-10 bg-white/5 border border-[#ffffff39] rounded-full flex justify-center items-center"
@click="() => {
playTheAlbum()
playQueue.shuffleCurrent = true
playQueue.playMode.shuffle = true
}">
<ShuffleIcon :size="4" />
</button>
<button
class="text-white w-10 h-10 bg-white/5 border border-[#ffffff39] rounded-full flex justify-center items-center">
<StarEmptyIcon :size="4" />
</button>
</div>
<div class="text-sm text-gray-500 font-medium">
{{ album?.songs?.length ?? '' }} 首曲目
</div>
</div>
<div class="flex flex-col gap-2">
<TrackItem v-for="(track, index) in album?.songs" :key="track.cid" :album="album" :track="track" :index="index"
:playfrom="playTheAlbum" />
</div>
</div>
</div>
</template>

View File

@ -6,7 +6,7 @@ import AlbumDetailDialog from '../components/AlbumDetailDialog.vue'
const albums = ref([] as AlbumList)
const presentAlbumDetailDialog = ref(false)
const presentedAlbum = ref("")
const presentedAlbum = ref('')
onMounted(async () => {
const res = await apis.getAlbums()

View File

@ -6,50 +6,57 @@ import ShuffleIcon from '../assets/icons/shuffle.vue'
import { useFavourites } from '../stores/useFavourites'
import { ref } from 'vue'
import PlayListItem from '../components/PlayListItem.vue'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { usePlayStore } from '../stores/usePlayStore'
const favourites = useFavourites()
const playQueueStore = usePlayQueueStore()
const playQueueStore = usePlayStore()
const currentList = ref<'favourites' | number>('favourites')
function playTheList(list: 'favourites' | number, playFrom: number = 0) {
if (playFrom < 0 || playFrom >= favourites.favouritesCount) { playFrom = 0 }
if (usePlayQueueStore().queueReplaceLock) {
if (!confirm("当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?")) { return }
usePlayQueueStore().queueReplaceLock = false
function playTheList(list: 'favourites' | number, playFrom = 0) {
let actualPlayFrom = playFrom
if (playFrom < 0 || playFrom >= favourites.favouritesCount) {
actualPlayFrom = 0
}
if (playQueueStore.queueReplaceLock) {
if (
!confirm(
'当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?',
)
) {
return
}
playQueueStore.queueReplaceLock = false
}
playQueueStore.list = []
if (list === 'favourites') {
if (favourites.favouritesCount === 0) return
let newPlayQueue = favourites.favourites.map(item => ({
let newPlayQueue = favourites.favourites.map((item) => ({
song: item.song,
album: item.album
album: item.album,
}))
playQueueStore.list = newPlayQueue.slice().reverse()
playQueueStore.currentIndex = playFrom
playQueueStore.playMode.shuffle = false
playQueueStore.isPlaying = true
playQueueStore.isBuffering = true
// playQueueStore.list = newPlayQueue.slice().reverse()
// playQueueStore.currentIndex = playFrom
// playQueueStore.playMode.shuffle = false
// playQueueStore.isPlaying = true
// playQueueStore.isBuffering = true
} else {
// Handle other lists if needed
}
}
function shuffle(list: 'favourites' | number) {
playTheList(list)
playQueueStore.shuffleCurrent = true
playQueueStore.playMode.shuffle = false
setTimeout(() => {
playQueueStore.playMode.shuffle = true
playQueueStore.isPlaying = true
playQueueStore.isBuffering = true
}, 100)
// playTheList(list)
// playQueueStore.shuffleCurrent = true
// playQueueStore.playMode.shuffle = false
// setTimeout(() => {
// playQueueStore.playMode.shuffle = true
// playQueueStore.isPlaying = true
// playQueueStore.isBuffering = true
// }, 100)
}
</script>
<template>

View File

@ -1,8 +1,7 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize } from '../utils'
import { usePlayStore } from '../stores/usePlayStore'
import gsap from 'gsap'
import { Draggable } from "gsap/Draggable"
import { Draggable } from 'gsap/Draggable'
import { onMounted, onUnmounted, nextTick } from 'vue'
import { useTemplateRef } from 'vue'
import { ref, watch } from 'vue'
@ -30,8 +29,9 @@ import SpeakerIcon from '../assets/icons/speaker.vue'
import MuscialNoteSparklingIcon from '../assets/icons/musicalnotesparkling.vue'
import CastEmptyIcon from '../assets/icons/castempty.vue'
const playQueueStore = usePlayQueueStore()
const playStore = usePlayStore()
const preferences = usePreferences()
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const favourites = useFavourites()
gsap.registerPlugin(Draggable)
@ -52,6 +52,7 @@ const volumeSliderContainer = useTemplateRef('volumeSliderContainer')
const presentQueueListDialog = ref(false)
const presentLyrics = ref(false)
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
const showLyricsTooltip = ref(false)
const showMoreOptions = ref(false)
const presentVolumeControl = ref(false)
@ -66,9 +67,10 @@ onMounted(async () => {
onDrag: function () {
const thumbPosition = this.x
const containerWidth = progressBarContainer.value?.clientWidth || 0
const newTime = (thumbPosition / containerWidth) * playQueueStore.duration
playQueueStore.updatedCurrentTime = newTime
}
const newTime =
(thumbPosition / containerWidth) * playStore.progress.duration
playStore.updateCurrentTime(newTime)
},
})
// DOM
@ -77,7 +79,7 @@ onMounted(async () => {
// localStorage
const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) {
volume.value = parseFloat(savedVolume)
volume.value = Number.parseFloat(savedVolume)
}
thumbUpdate()
@ -88,25 +90,33 @@ onMounted(async () => {
setupPageFocusHandlers()
})
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function timeFormatter(time: number) {
const timeInSeconds = Math.floor(time)
if (timeInSeconds < 0) { return '-:--' }
if (timeInSeconds < 0) {
return '-:--'
}
const minutes = Math.floor(timeInSeconds / 60)
const seconds = Math.floor(timeInSeconds % 60)
if (Number.isNaN(minutes) || Number.isNaN(seconds)) { return '-:--' }
if (Number.isNaN(minutes) || Number.isNaN(seconds)) {
return '-:--'
}
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
//
watch(() => playQueueStore.currentTime, () => {
thumbUpdate()
})
watch(
() => playStore.progress.currentTime,
() => {
thumbUpdate()
},
)
function thumbUpdate() {
const progress = playQueueStore.currentTime / playQueueStore.duration
const progress = playStore.progress.percentage
const containerWidth = progressBarContainer.value?.clientWidth || 0
const thumbWidth = progressBarThumb.value?.clientWidth || 0
const newPosition = (containerWidth - thumbWidth) * progress
const newPosition = ((containerWidth - thumbWidth) * progress) / 100
gsap.to(progressBarThumb.value, { x: newPosition, duration: 0.1 })
}
@ -117,6 +127,7 @@ function volumeThumbUpdate() {
gsap.to(volumeSliderThumb.value, { x: newPosition, duration: 0.1 })
}
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function toggleVolumeControl() {
if (!presentVolumeControl.value) {
presentVolumeControl.value = true
@ -152,7 +163,10 @@ function createVolumeDraggable() {
const containerWidth = volumeSliderContainer.value?.clientWidth || 0
const thumbWidth = volumeSliderThumb.value?.clientWidth || 0
// 0-1
const newVolume = Math.max(0, Math.min(1, thumbPosition / (containerWidth - thumbWidth)))
const newVolume = Math.max(
0,
Math.min(1, thumbPosition / (containerWidth - thumbWidth)),
)
volume.value = newVolume
updateAudioVolume()
// localStorage
@ -161,7 +175,7 @@ function createVolumeDraggable() {
onDragEnd: () => {
//
localStorage.setItem('audioVolume', volume.value.toString())
}
},
})
console.log('Volume draggable created successfully')
@ -175,80 +189,67 @@ function updateAudioVolume() {
}
}
// biome-ignore lint/correctness/noUnusedVariables: used in <template>
function formatDetector() {
const format = playQueueStore.list[playQueueStore.currentIndex].song.sourceUrl?.split('.').pop()
if (format === 'mp3') { return 'MP3' }
if (format === 'flac') { return 'FLAC' }
if (format === 'm4a') { return 'M4A' }
if (format === 'ape') { return 'APE' }
if (format === 'wav') { return 'WAV' }
const format = playStore.currentTrack?.url?.split('.').pop()
if (format === 'mp3') {
return 'MP3'
}
if (format === 'flac') {
return 'FLAC'
}
if (format === 'm4a') {
return 'M4A'
}
if (format === 'ape') {
return 'APE'
}
if (format === 'wav') {
return 'WAV'
}
return '未知格式'
}
function playNext() {
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
console.log("at the bottom, pause")
playQueueStore.currentIndex = 0
playQueueStore.isPlaying = false
} else {
playQueueStore.currentIndex++
playQueueStore.isPlaying = true
}
}
function playPrevious() {
if (playQueueStore.currentTime < 5 && playQueueStore.currentIndex > 0) {
playQueueStore.currentIndex--
playQueueStore.isPlaying = true
} else {
playQueueStore.updatedCurrentTime = 0
}
}
function setupEntranceAnimations() {
if (controllerRef.value) {
gsap.fromTo(controllerRef.value.children,
gsap.fromTo(
controllerRef.value.children,
{ opacity: 0, y: 30, scale: 0.95 },
{
opacity: 1, y: 0, scale: 1,
duration: 0.6, ease: "power2.out", stagger: 0.1
}
opacity: 1,
y: 0,
scale: 1,
duration: 0.6,
ease: 'power2.out',
stagger: 0.1,
},
)
}
if (lyricsSection.value) {
gsap.fromTo(lyricsSection.value,
gsap.fromTo(
lyricsSection.value,
{ 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 },
)
}
}
function handlePlayPause() {
if (playButton.value) {
gsap.to(playButton.value, {
scale: 0.9, duration: 0.1, yoyo: true, repeat: 1,
ease: "power2.inOut",
onComplete: () => {
playQueueStore.isPlaying = !playQueueStore.isPlaying
}
})
} else {
playQueueStore.isPlaying = !playQueueStore.isPlaying
}
playStore.togglePlay()
}
function toggleShuffle() {
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
playQueueStore.shuffleCurrent = false
// playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
// playQueueStore.shuffleCurrent = false
}
function toggleRepeat() {
switch (playQueueStore.playMode.repeat) {
case 'off': playQueueStore.playMode.repeat = 'all'; break
case 'all': playQueueStore.playMode.repeat = 'single'; break
case 'single': playQueueStore.playMode.repeat = 'off'; break
}
// switch (playQueueStore.playMode.repeat) {
// case 'off': playQueueStore.playMode.repeat = 'all'; break
// case 'all': playQueueStore.playMode.repeat = 'single'; break
// case 'single': playQueueStore.playMode.repeat = 'off'; break
// }
}
function makePlayQueueListPresent() {
@ -259,15 +260,26 @@ function makePlayQueueListPresent() {
const tl = gsap.timeline()
tl.to(playQueueDialogContainer.value, {
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out'
}).to(playQueueDialog.value, {
x: 0, duration: 0.4, ease: 'power3.out'
}, '<0.1')
backgroundColor: '#17171780',
duration: 0.3,
ease: 'power2.out',
}).to(
playQueueDialog.value,
{
x: 0,
duration: 0.4,
ease: 'power3.out',
},
'<0.1',
)
if (playQueueDialog.value.children.length > 0) {
tl.fromTo(playQueueDialog.value.children,
tl.fromTo(
playQueueDialog.value.children,
{ opacity: 0, x: -20 },
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 }, '<0.2')
{ opacity: 1, x: 0, duration: 0.3, ease: 'power2.out', stagger: 0.05 },
'<0.2',
)
}
})
}
@ -285,34 +297,44 @@ function makePlayQueueListDismiss() {
gsap.set(playQueueDialog.value, { x: -384 })
}
if (playQueueDialogContainer.value) {
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' })
gsap.set(playQueueDialogContainer.value, {
backgroundColor: 'transparent',
})
}
}
},
})
if (playQueueDialog.value.children.length > 0) {
tl.to(playQueueDialog.value.children, {
opacity: 0, x: -20, duration: 0.2, ease: 'power2.in', stagger: 0.03
opacity: 0,
x: -20,
duration: 0.2,
ease: 'power2.in',
stagger: 0.03,
})
}
tl.to(playQueueDialog.value, {
x: -384, duration: 0.3, ease: 'power2.in'
}, playQueueDialog.value.children.length > 0 ? '<0.1' : '0')
.to(playQueueDialogContainer.value, {
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in'
}, '<')
tl.to(
playQueueDialog.value,
{
x: -384,
duration: 0.3,
ease: 'power2.in',
},
playQueueDialog.value.children.length > 0 ? '<0.1' : '0',
).to(
playQueueDialogContainer.value,
{
backgroundColor: 'transparent',
duration: 0.2,
ease: 'power2.in',
},
'<',
)
}
function getCurrentTrack() {
if (playQueueStore.list.length === 0) {
return null
}
if (playQueueStore.playMode.shuffle) {
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]]
} else {
return playQueueStore.list[playQueueStore.currentIndex]
}
return playStore.currentTrack
}
function toggleMoreOptions() {
@ -321,15 +343,23 @@ function toggleMoreOptions() {
nextTick(() => {
if (moreOptionsDialog.value) {
const tl = gsap.timeline()
tl.fromTo(moreOptionsDialog.value,
tl.fromTo(
moreOptionsDialog.value,
{ 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) {
tl.fromTo(moreOptionsDialog.value.children[0].children,
tl.fromTo(
moreOptionsDialog.value.children[0].children,
{ opacity: 0, x: -10 },
{ opacity: 1, x: 0, duration: 0.15, ease: "power2.out", stagger: 0.05 },
"<0.1"
{
opacity: 1,
x: 0,
duration: 0.15,
ease: 'power2.out',
stagger: 0.05,
},
'<0.1',
)
}
}
@ -339,16 +369,21 @@ function toggleMoreOptions() {
const tl = gsap.timeline({
onComplete: () => {
showMoreOptions.value = false
}
},
})
if (moreOptionsDialog.value.children[0]?.children) {
tl.to(moreOptionsDialog.value.children[0].children,
{ opacity: 0, x: -10, duration: 0.1, ease: "power2.in", stagger: 0.02 }
)
tl.to(moreOptionsDialog.value.children[0].children, {
opacity: 0,
x: -10,
duration: 0.1,
ease: 'power2.in',
stagger: 0.02,
})
}
tl.to(moreOptionsDialog.value,
{ opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: "power2.in" },
moreOptionsDialog.value.children[0]?.children ? "<0.05" : "0"
tl.to(
moreOptionsDialog.value,
{ opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: 'power2.in' },
moreOptionsDialog.value.children[0]?.children ? '<0.05' : '0',
)
} else {
showMoreOptions.value = false
@ -356,71 +391,97 @@ function toggleMoreOptions() {
}
}
watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newValue, oldValue) => {
if (!getCurrentTrack()) { return }
const [showLyrics, hasLyricUrl] = newValue
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"
}
// TODO: lyrics
watch(
() => [preferences.presentLyrics, getCurrentTrack()?.extra?.lyric],
(newValue, oldValue) => {
if (!getCurrentTrack()) {
return
}
const tl = gsap.timeline({
onComplete: () => {
presentLyrics.value = false
}
})
const [showLyrics, hasLyricUrl] = newValue
const [prevShowLyrics, _prevHasLyricUrl] = oldValue || [false, null]
tl.to(controllerRef.value, {
marginLeft: '44rem',
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
// 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',
)
})
.set(controllerRef.value, {
marginLeft: '0rem' // Reset for next time
}
// Hide lyrics with different animations based on reason
else if (presentLyrics.value) {
let animationConfig: {
opacity: number
x: number
scale?: number
duration: number
ease: string
}
// 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({
onComplete: () => {
presentLyrics.value = false
},
})
}
}, { immediate: true })
tl.to(controllerRef.value, {
marginLeft: '44rem',
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, {
marginLeft: '0rem', // Reset for next time
})
}
},
{ immediate: true },
)
//
let handleVisibilityChange: (() => void) | null = null
@ -466,7 +527,9 @@ function setupPageFocusHandlers() {
//
function resyncLyricsState() {
const currentTrack = getCurrentTrack()
if (!currentTrack) { return }
if (!currentTrack) {
return
}
console.log('[Playroom] 重新同步歌词状态')
@ -474,7 +537,7 @@ function resyncLyricsState() {
if (controllerRef.value) {
gsap.set(controllerRef.value, {
marginLeft: '0rem',
marginRight: '0rem'
marginRight: '0rem',
})
}
@ -483,15 +546,19 @@ function resyncLyricsState() {
opacity: 1,
x: 0,
y: 0,
scale: 1
scale: 1,
})
}
//
const shouldShowLyrics = preferences.presentLyrics && currentTrack.song.lyricUrl ? true : false
const shouldShowLyrics = !!(
preferences.presentLyrics && currentTrack.extra?.lyric
)
if (shouldShowLyrics !== presentLyrics.value) {
console.log(`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`)
console.log(
`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`,
)
//
presentLyrics.value = shouldShowLyrics
@ -503,11 +570,12 @@ function resyncLyricsState() {
tl.from(controllerRef.value, {
marginRight: '-40rem',
duration: 0.4,
ease: "power2.out"
}).fromTo(lyricsSection.value,
ease: 'power2.out',
}).fromTo(
lyricsSection.value,
{ opacity: 0, x: 50, scale: 0.95 },
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" },
"-=0.2"
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: 'power2.out' },
'-=0.2',
)
})
}
@ -515,29 +583,37 @@ function resyncLyricsState() {
}
// New: Watch for track changes and animate
watch(() => playQueueStore.currentIndex, () => {
if (albumCover.value) {
gsap.to(albumCover.value, {
scale: 0.95, opacity: 0.7, duration: 0.2,
ease: "power2.inOut", yoyo: true, repeat: 1
})
}
watch(
() => playStore.currentTrack,
() => {
if (albumCover.value) {
gsap.to(albumCover.value, {
scale: 0.95,
opacity: 0.7,
duration: 0.2,
ease: 'power2.inOut',
yoyo: true,
repeat: 1,
})
}
if (songInfo.value) {
gsap.fromTo(songInfo.value,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: "power2.out", delay: 0.3 }
)
}
})
if (songInfo.value) {
gsap.fromTo(
songInfo.value,
{ opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', delay: 0.3 },
)
}
},
)
</script>
<template>
<div v-if="getCurrentTrack() !== null">
<!-- Background remains unchanged -->
<div class="z-0 absolute top-0 left-0 w-screen h-screen overflow-hidden"
v-if="getCurrentTrack()?.album?.coverDeUrl">
<img class="w-full h-full blur-2xl object-cover scale-110" :src="getCurrentTrack()?.album?.coverDeUrl" />
v-if="getCurrentTrack()?.extra?.background">
<img class="w-full h-full blur-2xl object-cover scale-110" :src="getCurrentTrack()?.extra?.background as string | undefined ?? ''" />
<div class="w-full h-full absolute top-0 left-0 bg-neutral-900/5" />
</div>
@ -549,9 +625,9 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex flex-col w-96 gap-4" ref="controllerRef">
<!-- Album cover with enhanced hover effect -->
<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()?.metadata?.artwork?.[0].src" class="rounded-2xl shadow-2xl border border-white/20 w-96 h-96
transition-transform duration-300
" :class="playQueueStore.isPlaying ? 'scale-100' : 'scale-85'" />
" :class="playStore.isPlaying ? 'scale-100' : 'scale-85'" />
</div>
<!-- Song info with enhanced styling -->
@ -560,26 +636,26 @@ watch(() => playQueueStore.currentIndex, () => {
<!-- ...existing song info code... -->
<div class="">
<div class="text-black/90 blur-lg text-lg font-medium truncate w-80">
{{ getCurrentTrack()?.song.name }}
{{ getCurrentTrack()?.metadata?.title }}
</div>
<div class="text-black/90 blur-lg text-base truncate w-80">
{{ getCurrentTrack()?.song.artists ?? [] }}
{{ getCurrentTrack()?.album?.name ?? '未知专辑' }}
{{ getCurrentTrack()?.metadata?.artist}}
{{ getCurrentTrack()?.metadata?.album ?? '未知专辑' }}
</div>
</div>
<div class="absolute top-0">
<div class="text-white text-lg font-medium truncate w-80">
{{ getCurrentTrack()?.song.name }}
{{ getCurrentTrack()?.metadata?.title }}
</div>
<div class="text-white/75 text-base truncate w-80">
{{ artistsOrganize(getCurrentTrack()?.song.artists ?? []) }}
{{ getCurrentTrack()?.album?.name ?? '未知专辑' }}
{{ getCurrentTrack()?.metadata?.artist }}
{{ getCurrentTrack()?.metadata?.album ?? '未知专辑' }}
</div>
</div>
</div>
<button
<!-- <button
class="h-10 w-10 flex justify-center items-center rounded-full backdrop-blur-3xl transition-all duration-200 hover:scale-110"
ref="favoriteButton"
@click="() => { const track = getCurrentTrack(); if (track !== null) favourites.toggleFavourite(track) }"
@ -590,7 +666,7 @@ watch(() => playQueueStore.currentIndex, () => {
:size="6" />
<StarEmptyIcon v-else :size="6" />
</span>
</button>
</button> -->
</div>
<!-- Progress section -->
@ -607,9 +683,9 @@ watch(() => playQueueStore.currentIndex, () => {
<!-- ...existing time display code... -->
<div class="font-medium flex-1 text-left text-xs relative">
<span
class="text-black blur-lg absolute top-0 text-xs">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
class="text-black blur-lg absolute top-0 text-xs">{{ timeFormatter(Math.floor(playStore.progress.currentTime)) }}</span>
<span
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playStore.progress.currentTime)) }}</span>
</div>
<div class="text-xs text-center relative flex-1">
<span class="text-black blur-lg absolute top-0">{{ formatDetector() }}</span>
@ -621,8 +697,8 @@ watch(() => playQueueStore.currentIndex, () => {
class="text-white/90 text-xs font-medium text-right relative transition-colors duration-200 hover:text-white"
@click="preferences.displayTimeLeft = !preferences.displayTimeLeft">
<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(playQueueStore.duration) - Math.floor(playQueueStore.currentTime) : playQueueStore.duration)}` }}</span>
class="text-black blur-lg absolute top-0">{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playStore.progress.duration) - Math.floor(playStore.progress.currentTime) : playStore.progress.duration)}` }}</span>
<span>{{ `${preferences.displayTimeLeft ? '-' : ''}${timeFormatter(preferences.displayTimeLeft ? Math.floor(playStore.progress.duration) - Math.floor(playStore.progress.currentTime) : playStore.progress.duration)}` }}</span>
</button>
</div>
</div>
@ -684,16 +760,16 @@ watch(() => playQueueStore.currentIndex, () => {
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">
<!-- ...existing play/pause icon code... -->
<div v-if="playQueueStore.isPlaying">
<div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
<div v-if="playStore.isPlaying">
<!-- <div v-if="playQueueStore.isBuffering" class="w-6 h-6 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<LoadingIndicator :size="6" />
</span>
<span class="text-white absolute top-0 left-0">
<LoadingIndicator :size="6" />
</span>
</div>
<div v-else class="w-8 h-8 relative">
</div> -->
<div class="w-8 h-8 relative">
<span class="text-black blur-md absolute top-0 left-0">
<PauseIcon :size="8" />
</span>
@ -716,7 +792,7 @@ watch(() => playQueueStore.currentIndex, () => {
<button
class="text-white flex-1 h-10 flex justify-center items-center rounded-lg hover:bg-white/25 transition-all duration-200 hover:scale-105"
@click="playNext" ref="nextButton">
@click="playStore.skipToPrevious" ref="nextButton">
<div class="w-8 h-8 relative">
<span class="text-black/80 blur-lg absolute top-0 left-0">
<ForwardIcon :size="8" />
@ -748,7 +824,7 @@ watch(() => playQueueStore.currentIndex, () => {
</button>
<!-- Show tooltip only on hover, with transition -->
<transition name="lyrics-tooltip-fade">
<div v-if="showLyricsTooltip && !getCurrentTrack()?.song.lyricUrl"
<div v-if="showLyricsTooltip && !getCurrentTrack()?.extra?.lyric"
class="absolute bottom-10 w-60 left-[-7rem] bg-black/60 backdrop-blur-3xl rounded-md p-2 text-xs flex flex-col text-left shadow-2xl border border-[#ffffff39]">
<div class="font-semibold text-white">这首曲目不提供歌词文本</div>
<div class="text-white/60">启用歌词时将会在下一首有歌词的曲目中显示歌词文本</div>
@ -802,7 +878,7 @@ watch(() => playQueueStore.currentIndex, () => {
<!-- Lyrics section - full screen height -->
<div class="w-[40rem] h-screen" ref="lyricsSection" v-if="presentLyrics">
<ScrollingLyrics :lrcSrc="getCurrentTrack()?.song.lyricUrl ?? undefined" class="h-full"
<ScrollingLyrics :lrcSrc="getCurrentTrack()?.extra?.lyric as string ?? undefined" class="h-full"
ref="scrollingLyrics" />
</div>
</div>
@ -826,29 +902,29 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex gap-2 mx-8 mb-4">
<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="playQueueStore.playMode.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
:class="playStore.shuffle ? 'bg-[#ffffffaa] text-neutral-700' : 'text-white bg-neutral-800/80'"
@click="toggleShuffle">
<ShuffleIcon :size="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="playQueueStore.playMode.repeat === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
:class="playStore.loop === 'off' ? 'text-white bg-neutral-800/80' : 'bg-[#ffffffaa] text-neutral-700'"
@click="toggleRepeat">
<CycleTwoArrowsIcon :size="4" v-if="playQueueStore.playMode.repeat !== 'single'" />
<CycleTwoArrowsIcon :size="4" v-if="playStore.loop !== 'single_track'" />
<CycleTwoArrowsWithNumOneIcon :size="4" v-else />
</button>
</div>
<hr class="border-[#ffffff39]" />
<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.shuffleList"
<div class="flex-auto h-0 overflow-y-auto px-4 flex flex-col gap-2" v-if="playStore.shuffle">
<!-- <PlayQueueItem v-for="(oriIndex, shuffledIndex) in playStore.shuffleList"
:queueItem="playQueueStore.list[oriIndex]" :isCurrent="playQueueStore.currentIndex === shuffledIndex"
:key="playQueueStore.list[oriIndex].song.cid" :index="shuffledIndex" />
:key="playQueueStore.list[oriIndex].song.cid" :index="shuffledIndex" /> -->
</div>
<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"
v-for="(track, index) in playQueueStore.list" :index="index" :key="track.song.cid" />
<!-- <PlayQueueItem :queueItem="track" :isCurrent="playQueueStore.currentIndex === index"
v-for="(track, index) in playQueueStore.list" :index="index" :key="track.song.cid" /> -->
</div>
</div>
</dialog>

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia"
import { ref, watch, computed } from "vue"
import { defineStore } from 'pinia'
import { ref, watch, computed } from 'vue'
// 声明全局类型
declare global {
@ -9,19 +9,23 @@ declare global {
}
export const useFavourites = defineStore('favourites', () => {
const favourites = ref<QueueItem[]>([])
const favourites = ref<InternalQueueItem[]>([])
const isLoaded = ref(false)
const storageType = ref<'chrome' | 'localStorage' | 'memory'>('chrome')
// 默认收藏列表
const defaultFavourites: QueueItem[] = []
const defaultFavourites: InternalQueueItem[] = []
// 检测可用的 API
const detectAvailableAPIs = () => {
// 检查原生 chrome API
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
if (
typeof chrome !== 'undefined' &&
chrome.storage &&
chrome.storage.local
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -31,7 +35,11 @@ export const useFavourites = defineStore('favourites', () => {
// 检查 window.chrome
try {
if (window.chrome && window.chrome.storage && window.chrome.storage.local) {
if (
window.chrome &&
window.chrome.storage &&
window.chrome.storage.local
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -128,53 +136,65 @@ export const useFavourites = defineStore('favourites', () => {
}
// 数据验证和规范化函数
const normalizeFavourites = (data: any[]): QueueItem[] => {
const normalizeFavourites = (data: any[]): InternalQueueItem[] => {
if (!Array.isArray(data)) return []
return data.map(item => {
if (!item || !item.song) return null
return data
.map((item) => {
if (!item || !item.song) return null
// 规范化 Song 对象
const song: Song = {
cid: item.song.cid || '',
name: item.song.name || '',
albumCid: item.song.albumCid,
sourceUrl: item.song.sourceUrl,
lyricUrl: item.song.lyricUrl,
mvUrl: item.song.mvUrl,
mvCoverUrl: item.song.mvCoverUrl,
// 确保 artistes 和 artists 是数组
artistes: Array.isArray(item.song.artistes) ? 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) :
[]
}
// 规范化 Song 对象
const song: Song = {
cid: item.song.cid || '',
name: item.song.name || '',
albumCid: item.song.albumCid,
sourceUrl: item.song.sourceUrl,
lyricUrl: item.song.lyricUrl,
mvUrl: item.song.mvUrl,
mvCoverUrl: item.song.mvCoverUrl,
// 确保 artistes 和 artists 是数组
artistes: Array.isArray(item.song.artistes)
? 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)
: [],
}
// 规范化 Album 对象(如果存在)
const album = item.album ? {
cid: item.album.cid || '',
name: item.album.name || '',
intro: item.album.intro,
belong: item.album.belong,
coverUrl: item.album.coverUrl || '',
coverDeUrl: item.album.coverDeUrl,
artistes: Array.isArray(item.album.artistes) ? item.album.artistes :
typeof item.album.artistes === 'object' ? Object.values(item.album.artistes) :
[],
songs: item.album.songs
} : undefined
// 规范化 Album 对象(如果存在)
const album = item.album
? {
cid: item.album.cid || '',
name: item.album.name || '',
intro: item.album.intro,
belong: item.album.belong,
coverUrl: item.album.coverUrl || '',
coverDeUrl: item.album.coverDeUrl,
artistes: Array.isArray(item.album.artistes)
? item.album.artistes
: typeof item.album.artistes === 'object'
? Object.values(item.album.artistes)
: [],
songs: item.album.songs,
}
: undefined
return { song, album }
}).filter(Boolean) as QueueItem[]
return { song, album }
})
.filter(Boolean) as InternalQueueItem[]
}
// 获取收藏列表
const getFavourites = async () => {
const result = await getStoredValue('favourites', defaultFavourites)
// 确保返回的是数组并进行数据规范化
const normalizedResult = Array.isArray(result) ? normalizeFavourites(result) : defaultFavourites
const normalizedResult = Array.isArray(result)
? normalizeFavourites(result)
: defaultFavourites
return normalizedResult
}
@ -187,11 +207,11 @@ export const useFavourites = defineStore('favourites', () => {
// 检查歌曲是否已收藏
const isFavourite = (songCid: string): boolean => {
return favourites.value.some(item => item.song.cid === songCid)
return favourites.value.some((item) => item.song.cid === songCid)
}
// 添加到收藏
const addToFavourites = async (queueItem: QueueItem) => {
const addToFavourites = async (queueItem: InternalQueueItem) => {
if (!isFavourite(queueItem.song.cid)) {
favourites.value.push(queueItem)
if (isLoaded.value) {
@ -208,7 +228,9 @@ export const useFavourites = defineStore('favourites', () => {
// 从收藏中移除
const removeFromFavourites = async (songCid: string) => {
const index = favourites.value.findIndex(item => item.song.cid === songCid)
const index = favourites.value.findIndex(
(item) => item.song.cid === songCid,
)
if (index !== -1) {
const removedItem = favourites.value.splice(index, 1)[0]
if (isLoaded.value) {
@ -224,7 +246,7 @@ export const useFavourites = defineStore('favourites', () => {
}
// 切换收藏状态
const toggleFavourite = async (queueItem: QueueItem) => {
const toggleFavourite = async (queueItem: InternalQueueItem) => {
if (isFavourite(queueItem.song.cid)) {
await removeFromFavourites(queueItem.song.cid)
} else {
@ -265,29 +287,38 @@ export const useFavourites = defineStore('favourites', () => {
// 监听变化并保存(防抖处理)
let saveTimeout: NodeJS.Timeout | null = null
watch(favourites, async () => {
if (isLoaded.value) {
// 清除之前的定时器
if (saveTimeout) {
clearTimeout(saveTimeout)
}
// 设置新的定时器,防抖保存
saveTimeout = setTimeout(async () => {
try {
await saveFavourites()
} catch (error) {
// Silent fail
watch(
favourites,
async () => {
if (isLoaded.value) {
// 清除之前的定时器
if (saveTimeout) {
clearTimeout(saveTimeout)
}
}, 300)
}
}, { deep: true })
// 设置新的定时器,防抖保存
saveTimeout = setTimeout(async () => {
try {
await saveFavourites()
} catch (error) {
// Silent fail
}
}, 300)
}
},
{ deep: true },
)
// 更新收藏列表中的歌曲信息
const updateSongInFavourites = async (songCid: string, updatedSong: Song) => {
const index = favourites.value.findIndex(item => item.song.cid === songCid)
const index = favourites.value.findIndex(
(item) => item.song.cid === songCid,
)
if (index !== -1) {
// 更新歌曲信息,保持其他属性不变
favourites.value[index].song = { ...favourites.value[index].song, ...updatedSong }
favourites.value[index].song = {
...favourites.value[index].song,
...updatedSong,
}
if (isLoaded.value) {
try {
await saveFavourites()
@ -317,7 +348,6 @@ export const useFavourites = defineStore('favourites', () => {
clearFavourites,
getStoredValue,
setStoredValue,
updateSongInFavourites
updateSongInFavourites,
}
})

View File

@ -1,217 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { checkAndRefreshSongResource } from '../utils'
export const usePlayQueueStore = defineStore('queue', () => {
const list = ref<QueueItem[]>([])
const currentIndex = ref<number>(0)
const isPlaying = ref<boolean>(false)
const queueReplaceLock = ref<boolean>(false)
const isBuffering = ref<boolean>(false)
const currentTime = ref<number>(0)
const duration = ref<number>(0)
const updatedCurrentTime = ref<number | null>(null)
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
const shuffleList = ref<number[]>([])
const playMode = ref<{
shuffle: boolean
repeat: 'off' | 'single' | 'all'
}>({
shuffle: false,
repeat: 'off',
})
const shuffleCurrent = ref<boolean | undefined>(undefined)
// 预加载相关状态
const preloadedAudio = ref<Map<string, HTMLAudioElement>>(new Map())
const isPreloading = ref<boolean>(false)
const preloadProgress = ref<number>(0)
// 获取下一首歌的索引
const getNextIndex = computed(() => {
if (list.value.length === 0) return -1
if (playMode.value.repeat === 'single') {
return currentIndex.value
}
if (playMode.value.shuffle && shuffleList.value.length > 0) {
// 当前在 shuffleList 中的位置
const currentShuffleIndex = currentIndex.value
if (currentShuffleIndex < shuffleList.value.length - 1) {
// 返回下一个位置对应的原始 list 索引
return shuffleList.value[currentShuffleIndex + 1]
} else if (playMode.value.repeat === 'all') {
// 返回第一个位置对应的原始 list 索引
return shuffleList.value[0]
}
return -1
}
if (currentIndex.value < list.value.length - 1) {
return currentIndex.value + 1
} else if (playMode.value.repeat === 'all') {
return 0
}
return -1
})
// 预加载下一首歌
const preloadNext = async () => {
const nextIndex = getNextIndex.value
if (nextIndex === -1) {
return
}
// 获取下一首歌曲对象
// nextIndex 已经是原始 list 中的索引
const nextSong = list.value[nextIndex]
if (!nextSong || !nextSong.song) {
return
}
const songId = nextSong.song.cid
// 如果已经预加载过,跳过
if (preloadedAudio.value.has(songId)) {
return
}
// 检查是否有有效的音频源
if (!nextSong.song.sourceUrl) {
return
}
try {
isPreloading.value = true
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
}
})
// 监听加载完成
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 getPreloadedAudio = (songId: string): HTMLAudioElement | null => {
const audio = preloadedAudio.value.get(songId) || null
return audio
}
// 清理预加载的音频
const clearPreloadedAudio = (songId: string) => {
const audio = preloadedAudio.value.get(songId)
if (audio) {
audio.pause()
audio.src = ''
preloadedAudio.value.delete(songId)
}
}
// 清理所有预加载的音频
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 {
list,
currentIndex,
isPlaying,
queueReplaceLock,
isBuffering,
currentTime,
duration,
updatedCurrentTime,
visualizer,
shuffleList,
playMode,
shuffleCurrent,
// 预加载相关 - 确保所有函数都在返回对象中
preloadedAudio,
isPreloading,
preloadProgress,
getNextIndex,
preloadNext,
getPreloadedAudio,
clearPreloadedAudio,
clearAllPreloadedAudio,
limitPreloadCache,
debugPreloadState,
}
})

136
src/stores/usePlayStore.ts Normal file
View File

@ -0,0 +1,136 @@
import { Player } from '@astrian/music-surge-revolution'
import type { QueueItem } from '@astrian/music-surge-revolution'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { artistsOrganize } from '../utils'
export const usePlayStore = defineStore('player', () => {
const player = ref(new Player())
const queueReplaceLock = ref(false)
const visualizer = ref([0, 0, 0, 0, 0, 0])
const progress = ref({
currentTime: 0,
duration: 0,
percentage: 0,
})
const currentTrack = ref<QueueItem>()
const isPlaying = ref(false)
const queue = ref<QueueItem[]>([])
const shuffle = ref(false)
const loop = ref<'off' | 'entire_queue' | 'single_track'>('off')
const replaceQueue = (
queue: {
song: Song
album: Album | undefined
}[],
) => {
const newQueue = []
for (const item of queue) {
console.log(item.song.artists ?? item.song.artistes)
newQueue.push({
url: item.song.sourceUrl ?? '',
metadata: {
title: item.song.name,
artist: artistsOrganize(
item.song.artists ?? item.song.artistes ?? [],
),
album: item.album?.name,
artwork: [
{
src: item.album?.coverUrl ?? '',
sizes: '500x500',
type: ((item.album?.coverUrl ?? '').split('.').at(-1) === 'jpg'
? 'image/jpeg'
: 'image/png') as 'image/jpeg' | 'image/png',
},
],
},
extra: {
lyric: item.song.lyricUrl,
background: item.album?.coverDeUrl,
},
})
}
player.value.replaceQueue(newQueue)
}
const togglePlay = (play?: boolean) => {
player.value.togglePlaying(play)
}
const appendItem = (item: {
song: Song
album: Album | undefined
}) => {
player.value.appendTrack({
url: item.song.sourceUrl ?? '',
metadata: {
title: item.song.name,
artist: artistsOrganize(item.song.artistes ?? item.song.artists ?? []),
artwork: [
{
src: item.album?.coverUrl ?? '',
sizes: '500x500',
type: ((item.album?.coverUrl ?? '').split('.').at(-1) === 'jpg'
? 'image/jpeg'
: 'image/png') as 'image/jpeg' | 'image/png',
},
],
},
extra: {
lyric: item.song.lyricUrl,
background: item.album?.coverDeUrl,
},
})
}
player.value.onProgressChange((params) => {
progress.value = params
})
player.value.onCurrentPlayingChange((params) => {
currentTrack.value = params
})
player.value.onPlayStateChange((params) => {
isPlaying.value = params
})
player.value.onQueueChange((params) => {
queue.value = params
})
player.value.onShuffleChange((params) => {
shuffle.value = params
})
player.value.onLoopChange((params) => {
loop.value = params
})
const updateCurrentTime = (time: number) => {
player.value.seekTo(time)
}
const skipToPrevious = () => {
player.value.skipToPrevious()
}
return {
queueReplaceLock,
togglePlay,
visualizer,
appendItem,
progress,
currentTrack,
updateCurrentTime,
skipToPrevious,
isPlaying,
queue,
shuffle,
loop,
replaceQueue,
}
})

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia"
import { ref, watch } from "vue"
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
// 声明全局类型
declare global {
@ -20,14 +20,18 @@ export const usePreferences = defineStore('preferences', () => {
const defaultPreferences = {
displayTimeLeft: false,
presentLyrics: false,
autoRedirect: true
autoRedirect: true,
}
// 检测可用的 API
const detectAvailableAPIs = () => {
// 检查原生 chrome API
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
if (
typeof chrome !== 'undefined' &&
chrome.storage &&
chrome.storage.sync
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -37,7 +41,11 @@ export const usePreferences = defineStore('preferences', () => {
// 检查 window.chrome
try {
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
if (
window.chrome &&
window.chrome.storage &&
window.chrome.storage.sync
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -143,7 +151,7 @@ export const usePreferences = defineStore('preferences', () => {
const preferences = {
displayTimeLeft: displayTimeLeft.value,
presentLyrics: presentLyrics.value,
autoRedirect: autoRedirect.value
autoRedirect: autoRedirect.value,
}
await setStoredValue('preferences', preferences)
}
@ -188,6 +196,6 @@ export const usePreferences = defineStore('preferences', () => {
getStoredValue,
setStoredValue,
getPreferences,
savePreferences
savePreferences,
}
})

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia"
import { ref } from "vue"
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 声明全局类型
declare global {
@ -26,7 +26,11 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
const detectAvailableAPIs = () => {
// 检查原生 chrome API
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
if (
typeof chrome !== 'undefined' &&
chrome.storage &&
chrome.storage.sync
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -36,7 +40,11 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
// 检查 window.chrome
try {
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
if (
window.chrome &&
window.chrome.storage &&
window.chrome.storage.sync
) {
storageType.value = 'chrome'
return 'chrome'
}
@ -143,7 +151,10 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
}
// 获取上次显示弹窗的版本号
const lastShownVersion = await getStoredValue('lastUpdatePopupVersion', '')
const lastShownVersion = await getStoredValue(
'lastUpdatePopupVersion',
'',
)
// 如果版本号不同,需要显示弹窗并更新存储的版本号
if (lastShownVersion !== currentVersion) {
@ -199,6 +210,6 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
getLastShownVersion,
initializeUpdatePopup,
getStoredValue,
setStoredValue
setStoredValue,
}
})

View File

@ -1,15 +1,15 @@
@import "tailwindcss";
/* 导入来自 /assets/MiSans_VF.ttf 的字体 */
@font-face {
font-family: 'MiSans';
src: url('/assets/MiSans_VF.ttf') format('truetype-variations');
font-family: "MiSans";
src: url("/assets/MiSans_VF.ttf") format("truetype-variations");
font-weight: 1 999;
font-display: swap;
}
@font-face {
font-family: 'Alte DIN';
src: url('/assets/din1451alt.ttf') format('truetype-variations');
font-family: "Alte DIN";
src: url("/assets/din1451alt.ttf") format("truetype-variations");
font-weight: 1 999;
font-display: swap;
}
@ -27,5 +27,5 @@ input {
}
.track_num {
font-family: 'DIN Alternate', 'Alte DIN' !important;
font-family: "DIN Alternate", "Alte DIN" !important;
}

View File

@ -1,6 +1,10 @@
export default (list: string[]) => {
if (list.length === 0) { return '未知音乐人' }
return list.map((artist) => {
return artist
}).join(' / ')
if (list.length === 0) {
return '未知音乐人'
}
return list
.map((artist) => {
return artist
})
.join(' / ')
}

View File

@ -2,377 +2,417 @@
import { ref, onUnmounted, Ref } from 'vue'
interface AudioVisualizerOptions {
sensitivity?: number
smoothing?: number
barCount?: number
debug?: boolean
bassBoost?: number // 低音增强倍数 (默认 0.7,降低低音)
midBoost?: number // 中音增强倍数 (默认 1.2)
trebleBoost?: number // 高音增强倍数 (默认 1.5)
threshold?: number // 响度门槛 (0-255默认 15)
minHeight?: number // 最小高度百分比 (默认 0)
maxDecibels?: number // 最大分贝门槛 (默认 -10越大越难顶满)
sensitivity?: number
smoothing?: number
barCount?: number
debug?: boolean
bassBoost?: number // 低音增强倍数 (默认 0.7,降低低音)
midBoost?: number // 中音增强倍数 (默认 1.2)
trebleBoost?: number // 高音增强倍数 (默认 1.5)
threshold?: number // 响度门槛 (0-255默认 15)
minHeight?: number // 最小高度百分比 (默认 0)
maxDecibels?: number // 最大分贝门槛 (默认 -10越大越难顶满)
}
export function audioVisualizer(options: AudioVisualizerOptions = {}) {
const {
sensitivity = 1,
smoothing = 0.7,
barCount = 4,
debug = false,
bassBoost = 0.7, // 降低低音权重
midBoost = 1.2, // 提升中音
trebleBoost = 1.5, // 提升高音
threshold = 15, // 响度门槛,低于此值不产生波动
minHeight = 0 // 最小高度百分比
} = options
const {
sensitivity = 1,
smoothing = 0.7,
barCount = 4,
debug = false,
bassBoost = 0.7, // 降低低音权重
midBoost = 1.2, // 提升中音
trebleBoost = 1.5, // 提升高音
threshold = 15, // 响度门槛,低于此值不产生波动
minHeight = 0, // 最小高度百分比
} = options
console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options)
console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options)
// 导出的竖杠高度值数组 (0-100)
const barHeights: Ref<number[]> = ref(Array(barCount).fill(0))
const isAnalyzing = ref(false)
const error = ref<string | null>(null)
const isInitialized = ref(false)
// 导出的竖杠高度值数组 (0-100)
const barHeights: Ref<number[]> = ref(Array(barCount).fill(0))
const isAnalyzing = ref(false)
const error = ref<string | null>(null)
const isInitialized = ref(false)
// 内部变量
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let dataArray: Uint8Array | null = null
let animationId: number | null = null
let currentAudioElement: HTMLAudioElement | null = null
// 内部变量
let audioContext: AudioContext | null = null
let analyser: AnalyserNode | null = null
let source: MediaElementAudioSourceNode | null = null
let dataArray: Uint8Array | null = null
let animationId: number | null = null
let currentAudioElement: HTMLAudioElement | null = null
// 调试日志
function log(...args: any[]) {
if (debug) {
console.log('[AudioVisualizer]', ...args)
}
}
// 调试日志
function log(...args: any[]) {
if (debug) {
console.log('[AudioVisualizer]', ...args)
}
}
// 初始化音频分析
function initAudioContext(audioElement: HTMLAudioElement) {
if (!audioElement) {
log('错误: 音频元素为空')
return
}
// 初始化音频分析
function initAudioContext(audioElement: HTMLAudioElement) {
if (!audioElement) {
log('错误: 音频元素为空')
return
}
if (audioContext) {
log('AudioContext 已存在,跳过初始化')
return
}
if (audioContext) {
log('AudioContext 已存在,跳过初始化')
return
}
try {
log('开始初始化音频上下文...')
try {
log('开始初始化音频上下文...')
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
log('AudioContext 创建成功, 状态:', audioContext.state, '采样率:', audioContext.sampleRate)
audioContext = new (
window.AudioContext || (window as any).webkitAudioContext
)()
log(
'AudioContext 创建成功, 状态:',
audioContext.state,
'采样率:',
audioContext.sampleRate,
)
// 如果上下文被暂停,尝试恢复
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
log('AudioContext 已恢复')
})
}
// 如果上下文被暂停,尝试恢复
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
log('AudioContext 已恢复')
})
}
analyser = audioContext.createAnalyser()
analyser = audioContext.createAnalyser()
// 尝试创建音频源
try {
source = audioContext.createMediaElementSource(audioElement)
log('MediaElementSource 创建成功')
} catch (sourceError) {
log('创建 MediaElementSource 失败:', sourceError)
error.value = 'CORS 错误: 无法访问跨域音频'
return
}
// 尝试创建音频源
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 // 使用配置的最大分贝门槛
// 优化分析器配置
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
})
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)
// 连接音频节点
source.connect(analyser)
analyser.connect(audioContext.destination)
// 创建数据数组
dataArray = new Uint8Array(analyser.frequencyBinCount)
// 创建数据数组
dataArray = new Uint8Array(analyser.frequencyBinCount)
isInitialized.value = true
error.value = null
log('✅ 音频可视化器初始化成功')
isInitialized.value = true
error.value = null
log('✅ 音频可视化器初始化成功')
} catch (err) {
log('❌ 音频上下文初始化失败:', err)
error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}`
isInitialized.value = false
}
}
} 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
}
// 开始分析
function startAnalysis() {
if (!analyser || !dataArray || !isInitialized.value) {
log('❌ 无法开始分析: 分析器未初始化')
return
}
log('✅ 开始频谱分析')
isAnalyzing.value = true
animate()
}
log('✅ 开始频谱分析')
isAnalyzing.value = true
animate()
}
// 停止分析
function stopAnalysis() {
log('停止频谱分析')
isAnalyzing.value = false
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
barHeights.value = Array(barCount).fill(0)
}
// 停止分析
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
// 动画循环
function animate() {
if (!isAnalyzing.value || !analyser || !dataArray || !audioContext) return
// 获取频率数据
analyser.getByteFrequencyData(dataArray)
// 获取频率数据
analyser.getByteFrequencyData(dataArray)
// 使用平衡的频段分割
const frequencyBands = divideFrequencyBandsBalanced(
dataArray,
barCount,
audioContext.sampleRate,
)
// 使用平衡的频段分割
const frequencyBands = divideFrequencyBandsBalanced(dataArray, barCount, audioContext.sampleRate)
// 应用频段特定的增强
const enhancedBands = applyFrequencyEnhancement(frequencyBands)
// 应用频段特定的增强
const enhancedBands = applyFrequencyEnhancement(frequencyBands)
// 更新竖杠高度 (0-100)
barHeights.value = enhancedBands.map((value) =>
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity)),
)
// 更新竖杠高度 (0-100)
barHeights.value = enhancedBands.map(value =>
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity))
)
animationId = requestAnimationFrame(animate)
}
animationId = requestAnimationFrame(animate)
}
// 平衡的频段分割 - 使用对数分布和人耳感知特性
function divideFrequencyBandsBalanced(
data: Uint8Array,
bands: number,
sampleRate: number,
): number[] {
const nyquist = sampleRate / 2
const result: number[] = []
// 平衡的频段分割 - 使用对数分布和人耳感知特性
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
// 定义人耳感知的频率范围 (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]
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)
// 将频率转换为 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)
// 确保范围有效
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}`)
}
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
// 计算该频段的 RMS (均方根) 值,而不是简单平均
let sumSquares = 0
let count = 0
for (let j = actualStart; j <= actualEnd; j++) {
const value = data[j]
sumSquares += value * value
count++
}
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)
}
const rms = count > 0 ? Math.sqrt(sumSquares / count) : 0
result.push(rms)
}
return result
}
return result
}
// 应用频段特定的增强和门槛
function applyFrequencyEnhancement(bands: number[]): number[] {
// 六个频段的增强倍数
const boosts = [bassBoost, bassBoost, midBoost, midBoost, trebleBoost, trebleBoost]
// 应用频段特定的增强和门槛
function applyFrequencyEnhancement(bands: number[]): number[] {
// 六个频段的增强倍数
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 // 返回最小高度对应的值
}
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
const boost = boosts[index] || 1
let enhanced = value * boost
// 应用压缩曲线,防止过度增强
enhanced = 255 * Math.tanh(enhanced / 255)
// 应用压缩曲线,防止过度增强
enhanced = 255 * Math.tanh(enhanced / 255)
return Math.min(255, Math.max(threshold, enhanced))
})
}
return Math.min(255, Math.max(threshold, enhanced))
})
}
// 连接音频元素
function connectAudio(audioElement: HTMLAudioElement) {
log('🔗 连接音频元素...')
// 连接音频元素
function connectAudio(audioElement: HTMLAudioElement) {
log('🔗 连接音频元素...')
if (currentAudioElement === audioElement) {
log('音频元素相同,跳过重复连接')
return
}
if (currentAudioElement === audioElement) {
log('音频元素相同,跳过重复连接')
return
}
// 清理旧的连接
cleanup()
// 清理旧的连接
cleanup()
currentAudioElement = audioElement
currentAudioElement = audioElement
// 等待音频加载完成后再初始化
if (audioElement.readyState >= 2) {
initAudioContext(audioElement)
} else {
audioElement.addEventListener('loadeddata', () => {
initAudioContext(audioElement)
}, { once: true })
}
// 等待音频加载完成后再初始化
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('play', startAnalysis)
audioElement.addEventListener('pause', stopAnalysis)
audioElement.addEventListener('ended', stopAnalysis)
// 监听错误
audioElement.addEventListener('error', (e) => {
log('❌ 音频加载错误:', e)
error.value = '音频加载失败'
})
}
// 监听错误
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 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 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
// 手动测试数据
function testWithFakeData() {
log('🧪 开始六频段模拟测试')
isAnalyzing.value = true
let testCount = 0
const maxTests = 50
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++
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)
}
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 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 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)
}
// 设置最大分贝门槛
function setMaxDecibels(newMaxDecibels: number) {
options.maxDecibels = Math.max(-100, Math.min(0, newMaxDecibels))
if (analyser) {
analyser.maxDecibels = options.maxDecibels
}
log('更新最大分贝门槛:', options.maxDecibels)
}
// 组件卸载时清理
onUnmounted(() => {
disconnectAudio()
})
// 组件卸载时清理
onUnmounted(() => {
disconnectAudio()
})
return {
barHeights,
isAnalyzing,
isInitialized,
error,
connectAudio,
disconnectAudio,
startAnalysis,
stopAnalysis,
testWithFakeData,
updateEnhancement,
setThreshold,
setMaxDecibels
}
return {
barHeights,
isAnalyzing,
isInitialized,
error,
connectAudio,
disconnectAudio,
startAnalysis,
stopAnalysis,
testWithFakeData,
updateEnhancement,
setThreshold,
setMaxDecibels,
}
}

View File

@ -11,13 +11,15 @@ export function isSafari(): boolean {
// 检测 Safari 浏览器(包括 iOS 和 macOS
// Safari 的 User Agent 包含 'safari' 但不包含 'chrome' 或 'chromium'
const isSafariBrowser = ua.includes('safari') &&
const isSafariBrowser =
ua.includes('safari') &&
!ua.includes('chrome') &&
!ua.includes('chromium') &&
!ua.includes('android')
// 额外检查:使用 Safari 特有的 API
const isSafariByFeature = 'safari' in window ||
const isSafariByFeature =
'safari' in window ||
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
return isSafariBrowser || isSafariByFeature
@ -28,7 +30,9 @@ export function isSafari(): boolean {
* @returns {boolean} Safari true false
*/
export function isMobileSafari(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
)
}
/**
@ -39,16 +43,20 @@ export function supportsWebAudioVisualization(): boolean {
// Safari 在某些情况下对 AudioContext 的支持有限制
// 特别是在处理跨域音频资源时
if (isSafari()) {
console.log('[BrowserDetection] Safari detected, audio visualization disabled')
console.log(
'[BrowserDetection] Safari detected, audio visualization disabled',
)
return false
}
// 检查基本的 Web Audio API 支持
const hasAudioContext = 'AudioContext' in window || 'webkitAudioContext' in window
const hasAnalyserNode = hasAudioContext && (
'AnalyserNode' in window ||
((window as any).AudioContext && 'createAnalyser' in (window as any).AudioContext.prototype)
)
const hasAudioContext =
'AudioContext' in window || 'webkitAudioContext' in window
const hasAnalyserNode =
hasAudioContext &&
('AnalyserNode' in window ||
((window as any).AudioContext &&
'createAnalyser' in (window as any).AudioContext.prototype))
return hasAudioContext && hasAnalyserNode
}
@ -93,6 +101,6 @@ export function getBrowserInfo() {
version: browserVersion,
isSafari: isSafari(),
isMobileSafari: isMobileSafari(),
supportsAudioVisualization: supportsWebAudioVisualization()
supportsAudioVisualization: supportsWebAudioVisualization(),
}
}

View File

@ -1,8 +1,16 @@
import artistsOrganize from "./artistsOrganize"
import { audioVisualizer } from "./audioVisualizer"
import cicdInfo from "./cicdInfo"
import { checkAndRefreshSongResource, checkAndRefreshMultipleSongs } from "./songResourceChecker"
import { isSafari, isMobileSafari, supportsWebAudioVisualization, getBrowserInfo } from "./browserDetection"
import artistsOrganize from './artistsOrganize'
import { audioVisualizer } from './audioVisualizer'
import cicdInfo from './cicdInfo'
import {
checkAndRefreshSongResource,
checkAndRefreshMultipleSongs,
} from './songResourceChecker'
import {
isSafari,
isMobileSafari,
supportsWebAudioVisualization,
getBrowserInfo,
} from './browserDetection'
export {
artistsOrganize,
@ -13,5 +21,5 @@ export {
isSafari,
isMobileSafari,
supportsWebAudioVisualization,
getBrowserInfo
getBrowserInfo,
}

View File

@ -8,51 +8,51 @@ import apis from '../apis'
* @returns
*/
export const checkAndRefreshSongResource = async (
song: Song,
updateCallback?: (updatedSong: Song) => void
song: Song,
updateCallback?: (updatedSong: Song) => void,
): Promise<Song> => {
if (!song.sourceUrl) {
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
return song
}
if (!song.sourceUrl) {
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
return song
}
try {
// 检查资源是否可用
await axios.head(song.sourceUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
params: {
_t: Date.now() // 添加时间戳参数避免缓存
},
timeout: 5000 // 5秒超时
})
try {
// 检查资源是否可用
await axios.head(song.sourceUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
},
params: {
_t: Date.now(), // 添加时间戳参数避免缓存
},
timeout: 5000, // 5秒超时
})
// 资源可用,返回原始歌曲
console.log('[ResourceChecker] 资源可用:', song.name)
return song
} catch (error) {
// 资源不可用,刷新歌曲信息
console.log('[ResourceChecker] 资源不可用,正在刷新:', song.name, error)
// 资源可用,返回原始歌曲
console.log('[ResourceChecker] 资源可用:', song.name)
return song
} catch (error) {
// 资源不可用,刷新歌曲信息
console.log('[ResourceChecker] 资源不可用,正在刷新:', song.name, error)
try {
const updatedSong = await apis.getSong(song.cid)
console.log('[ResourceChecker] 歌曲信息已刷新:', updatedSong.name)
try {
const updatedSong = await apis.getSong(song.cid)
console.log('[ResourceChecker] 歌曲信息已刷新:', updatedSong.name)
// 调用更新回调(如果提供)
if (updateCallback) {
updateCallback(updatedSong)
}
// 调用更新回调(如果提供)
if (updateCallback) {
updateCallback(updatedSong)
}
return updatedSong
} catch (refreshError) {
console.error('[ResourceChecker] 刷新歌曲信息失败:', refreshError)
// 刷新失败,返回原始歌曲
return song
}
}
return updatedSong
} catch (refreshError) {
console.error('[ResourceChecker] 刷新歌曲信息失败:', refreshError)
// 刷新失败,返回原始歌曲
return song
}
}
}
/**
@ -62,19 +62,19 @@ export const checkAndRefreshSongResource = async (
* @returns
*/
export const checkAndRefreshMultipleSongs = async (
songs: Song[],
updateCallback?: (updatedSong: Song, originalIndex: number) => void
songs: Song[],
updateCallback?: (updatedSong: Song, originalIndex: number) => void,
): Promise<Song[]> => {
const results: Song[] = []
const results: Song[] = []
for (let i = 0; i < songs.length; i++) {
const originalSong = songs[i]
const updatedSong = await checkAndRefreshSongResource(
originalSong,
updateCallback ? (updated) => updateCallback(updated, i) : undefined
)
results.push(updatedSong)
}
for (let i = 0; i < songs.length; i++) {
const originalSong = songs[i]
const updatedSong = await checkAndRefreshSongResource(
originalSong,
updateCallback ? (updated) => updateCallback(updated, i) : undefined,
)
results.push(updatedSong)
}
return results
return results
}

56
src/vite-env.d.ts vendored
View File

@ -5,26 +5,26 @@ type SongList = {
}
type Song = {
cid: string
name: string
albumCid?: string
sourceUrl?: string
lyricUrl?: string | null
mvUrl?: string | null
mvCoverUrl?: string | null
artistes?: string[]
artists?: string[]
cid: string
name: string
albumCid?: string
sourceUrl?: string
lyricUrl?: string | null
mvUrl?: string | null
mvCoverUrl?: string | null
artistes?: string[]
artists?: string[]
}
type Album = {
cid: string
name: string
intro?: string
belong?: string
coverUrl: string
coverDeUrl?: string
artistes: string[]
songs?: Song[]
cid: string
name: string
intro?: string
belong?: string
coverUrl: string
coverDeUrl?: string
artistes: string[]
songs?: Song[]
}
type AlbumList = Album[]
@ -35,21 +35,21 @@ interface ApiResponse {
data: unknown
}
interface QueueItem {
song: Song
album?: Album
interface InternalQueueItem {
song: Song
album?: Album
}
interface LyricsLine {
type: 'lyric'
time: number
text: string
originalTime: string
type: 'lyric'
time: number
text: string
originalTime: string
}
interface GapLine {
type: 'gap'
time: number
originalTime: string
duration?: number // 添加间隔持续时间
type: 'gap'
time: number
originalTime: string
duration?: number // 添加间隔持续时间
}

View File

@ -1,7 +1,7 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import path from "node:path"
import path from 'node:path'
// https://vite.dev/config/
export default defineConfig({
@ -26,9 +26,9 @@ export default defineConfig({
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
}
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})