fix: 修复播放间背景图像和歌词不显示的问题

This commit is contained in:
Astrian Zheng 2025-09-03 15:53:02 +10:00
parent f1fb5330a9
commit 4f0b897b4c
Signed by: Astrian
SSH Key Fingerprint: SHA256:rVnhx3DAKjujCwWE13aDl7uV6+9U1MvydLkNRXJrBiA
32 changed files with 1585 additions and 1200 deletions

6
package-lock.json generated
View File

@ -44,9 +44,9 @@
} }
}, },
"node_modules/@astrian/music-surge-revolution": { "node_modules/@astrian/music-surge-revolution": {
"version": "0.0.0-20250831055015", "version": "0.0.0-20250903052637",
"resolved": "https://registry.npmjs.org/@astrian/music-surge-revolution/-/music-surge-revolution-0.0.0-20250831055015.tgz", "resolved": "https://registry.npmjs.org/@astrian/music-surge-revolution/-/music-surge-revolution-0.0.0-20250903052637.tgz",
"integrity": "sha512-joXpUDjez+5M90C4RoGsfHZifXdUBhqSHH+kW3v6TDQJQZwh/sdof1ro4qYXG3/8D8AkfWdhFV3O1C8nxG6syw==" "integrity": "sha512-P/cuDEseY1Q/UU5NAcbi53vYGEsC/mlM6If7+gjXqayMiOrTdmHTPypp1A0CrsodAsR0NtUohbeaZHCHgGAd/A=="
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",

View File

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

View File

@ -5,12 +5,8 @@
"description": "塞壬唱片Monster Siren Records官网的替代前端。", "description": "塞壬唱片Monster Siren Records官网的替代前端。",
"content_scripts": [ "content_scripts": [
{ {
"matches": [ "matches": ["https://monster-siren.hypergryph.com/"],
"https://monster-siren.hypergryph.com/" "js": ["content.js"],
],
"js": [
"content.js"
],
"run_at": "document_end" "run_at": "document_end"
} }
], ],
@ -36,11 +32,7 @@
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
"permissions": [ "permissions": ["tabs", "webRequest", "storage"],
"tabs",
"webRequest",
"storage"
],
"content_security_policy": { "content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' http://localhost:5173; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:5173 https://monster-siren.hypergryph.com https://web.hycdn.cn https://res01.hycdn.cn; img-src 'self' https://web.hycdn.cn; media-src 'self' https://res01.hycdn.cn;", "extension_pages": "default-src 'self'; script-src 'self' http://localhost:5173; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://localhost:5173 https://monster-siren.hypergryph.com https://web.hycdn.cn https://res01.hycdn.cn; img-src 'self' https://web.hycdn.cn; media-src 'self' https://res01.hycdn.cn;",
"sandbox": "sandbox" "sandbox": "sandbox"

View File

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

View File

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

View File

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

View File

@ -16,10 +16,12 @@ const presentPreferencePanel = ref(false)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
watch(() => presentPreferencePanel, (value) => { watch(
() => presentPreferencePanel,
(value) => {
console.log(value) console.log(value)
}) },
)
</script> </script>
<template> <template>

View File

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

View File

@ -26,7 +26,9 @@ const closeButton = ref<HTMLElement>()
// Animation functions // Animation functions
const animateIn = async () => { const animateIn = async () => {
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {return} if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {
return
}
// Set initial states // Set initial states
gsap.set(dialogBackdrop.value, { opacity: 0 }) gsap.set(dialogBackdrop.value, { opacity: 0 })
@ -39,48 +41,66 @@ const animateIn = async () => {
tl.to(dialogBackdrop.value, { tl.to(dialogBackdrop.value, {
opacity: 1, opacity: 1,
duration: 0.3, duration: 0.3,
ease: "power2.out" ease: 'power2.out',
}) })
.to(dialogContent.value, { .to(
dialogContent.value,
{
y: 0, y: 0,
opacity: 1, opacity: 1,
scale: 1, scale: 1,
duration: 0.4, duration: 0.4,
ease: "power3.out" ease: 'power3.out',
}, "-=0.1") },
.to(closeButton.value, { '-=0.1',
)
.to(
closeButton.value,
{
scale: 1, scale: 1,
rotation: 0, rotation: 0,
duration: 0.3, duration: 0.3,
ease: "back.out(1.7)" ease: 'back.out(1.7)',
}, "-=0.2") },
'-=0.2',
)
} }
const animateOut = () => { const animateOut = () => {
if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {return} if (!dialogBackdrop.value || !dialogContent.value || !closeButton.value) {
return
}
const tl = gsap.timeline({ const tl = gsap.timeline({
onComplete: () => emit('dismiss') onComplete: () => emit('dismiss'),
}) })
tl.to(closeButton.value, { tl.to(closeButton.value, {
scale: 0, scale: 0,
rotation: 180, rotation: 180,
duration: 0.2, duration: 0.2,
ease: "power2.in" ease: 'power2.in',
}) })
.to(dialogContent.value, { .to(
dialogContent.value,
{
y: 30, y: 30,
opacity: 0, opacity: 0,
scale: 0.95, scale: 0.95,
duration: 0.3, duration: 0.3,
ease: "power2.in" ease: 'power2.in',
}, "-=0.1") },
.to(dialogBackdrop.value, { '-=0.1',
)
.to(
dialogBackdrop.value,
{
opacity: 0, opacity: 0,
duration: 0.2, duration: 0.2,
ease: "power2.in" ease: 'power2.in',
}, "-=0.1") },
'-=0.1',
)
} }
// biome-ignore lint/correctness/noUnusedVariables: used inside <template> // biome-ignore lint/correctness/noUnusedVariables: used inside <template>
@ -88,33 +108,47 @@ const handleClose = () => {
animateOut() animateOut()
} }
watch(() => props.present, async (newVal) => { watch(
() => props.present,
async (newVal) => {
if (newVal) { if (newVal) {
await nextTick() await nextTick()
animateIn() animateIn()
} }
}) },
)
watch(() => props.albumCid, async () => { watch(
console.log("AlbumDetailDialog mounted with albumCid:", props.albumCid) () => props.albumCid,
async () => {
console.log('AlbumDetailDialog mounted with albumCid:', props.albumCid)
album.value = undefined // Reset album when cid changes album.value = undefined // Reset album when cid changes
try { try {
const res = await apis.getAlbum(props.albumCid) const res = await apis.getAlbum(props.albumCid)
for (const track in res.songs) { for (const track in res.songs) {
res.songs[Number.parseInt(track, 10)] = await apis.getSong(res.songs[Number.parseInt(track, 10)].cid) res.songs[Number.parseInt(track, 10)] = await apis.getSong(
res.songs[Number.parseInt(track, 10)].cid,
)
} }
album.value = res album.value = res
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
}) },
)
const playQueue = usePlayStore() const playQueue = usePlayStore()
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>
function playTheAlbum(from = 0) { function playTheAlbum(from = 0) {
if (playQueue.queueReplaceLock) { if (playQueue.queueReplaceLock) {
if (!confirm("当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?")) { return } if (
!confirm(
'当前操作会将你的播放队列清空、放入这张专辑所有曲目,并从头播放。继续吗?',
)
) {
return
}
playQueue.queueReplaceLock = false playQueue.queueReplaceLock = false
} }
@ -123,7 +157,7 @@ function playTheAlbum(from = 0) {
console.log(track) console.log(track)
newPlayQueue.push({ newPlayQueue.push({
song: track, song: track,
album: album.value album: album.value,
}) })
} }
playQueue.replaceQueue(newPlayQueue) playQueue.replaceQueue(newPlayQueue)
@ -142,7 +176,6 @@ function shuffle() {
// playQueue.isBuffering = true // playQueue.isBuffering = true
// }, 100) // }, 100)
} }
</script> </script>
<template> <template>

View File

@ -63,7 +63,9 @@ function moveUp() {
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>
function moveDown() { 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 if (props.index === listLength - 1) return
playQueueStore.queueReplaceLock = true playQueueStore.queueReplaceLock = true
@ -114,7 +116,10 @@ function removeItem() {
playQueueStore.currentIndex-- playQueueStore.currentIndex--
} else if (props.index === playQueueStore.currentIndex) { } else if (props.index === playQueueStore.currentIndex) {
if (queue.length > 0) { if (queue.length > 0) {
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1) playQueueStore.currentIndex = Math.min(
playQueueStore.currentIndex,
queue.length - 1,
)
} else { } else {
playQueueStore.currentIndex = 0 playQueueStore.currentIndex = 0
} }
@ -145,7 +150,10 @@ function removeItem() {
playQueueStore.currentIndex-- playQueueStore.currentIndex--
} else if (props.index === playQueueStore.currentIndex) { } else if (props.index === playQueueStore.currentIndex) {
if (queue.length > 0) { if (queue.length > 0) {
playQueueStore.currentIndex = Math.min(playQueueStore.currentIndex, queue.length - 1) playQueueStore.currentIndex = Math.min(
playQueueStore.currentIndex,
queue.length - 1,
)
} else { } else {
playQueueStore.currentIndex = 0 playQueueStore.currentIndex = 0
} }

View File

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

View File

@ -135,17 +135,25 @@ const props = defineProps<{
// //
const scrollIndicatorHeight = computed(() => { const scrollIndicatorHeight = computed(() => {
if (parsedLyrics.value.length === 0) {return 0} if (parsedLyrics.value.length === 0) {
return Math.max(10, 100 / parsedLyrics.value.length * 5) // 5 return 0
}
return Math.max(10, (100 / parsedLyrics.value.length) * 5) // 5
}) })
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>
const scrollIndicatorPosition = computed(() => { 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 progress = currentLineIndex.value / (parsedLyrics.value.length - 1)
const containerHeight = lyricsContainer.value?.clientHeight || 400 const containerHeight = lyricsContainer.value?.clientHeight || 400
const indicatorTrackHeight = containerHeight / 2 // const indicatorTrackHeight = containerHeight / 2 //
return progress * (indicatorTrackHeight - (scrollIndicatorHeight.value / 100 * indicatorTrackHeight)) return (
progress *
(indicatorTrackHeight -
(scrollIndicatorHeight.value / 100) * indicatorTrackHeight)
)
}) })
// //
@ -157,15 +165,18 @@ function setLineRef(el: HTMLElement | null, index: number) {
} }
// //
function parseLyrics(lrcText: string, minGapDuration = 5): (LyricsLine | GapLine)[] { function parseLyrics(
lrcText: string,
minGapDuration = 5,
): (LyricsLine | GapLine)[] {
if (!lrcText) { if (!lrcText) {
return [ return [
{ {
type: 'lyric', type: 'lyric',
time: 0, time: 0,
text: '', text: '',
originalTime: '[00:00]' originalTime: '[00:00]',
} },
] ]
} }
@ -176,14 +187,18 @@ function parseLyrics(lrcText: string, minGapDuration = 5): (LyricsLine | GapLine
for (const line of lines) { for (const line of lines) {
const matches = [...line.matchAll(timeRegex)] 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() const text = line.replace(/\[\d{1,2}:\d{2}(?:\.\d{1,3})?\]/g, '').trim()
for (const match of matches) { for (const match of matches) {
const minutes = Number.parseInt(match[1]) const minutes = Number.parseInt(match[1])
const seconds = Number.parseInt(match[2]) const seconds = Number.parseInt(match[2])
const milliseconds = match[3] ? Number.parseInt(match[3].padEnd(3, '0')) : 0 const milliseconds = match[3]
? Number.parseInt(match[3].padEnd(3, '0'))
: 0
const totalSeconds = minutes * 60 + seconds + milliseconds / 1000 const totalSeconds = minutes * 60 + seconds + milliseconds / 1000
@ -192,13 +207,13 @@ function parseLyrics(lrcText: string, minGapDuration = 5): (LyricsLine | GapLine
type: 'lyric', type: 'lyric',
time: totalSeconds, time: totalSeconds,
text: text, text: text,
originalTime: match[0] originalTime: match[0],
}) })
} else { } else {
tempParsedLines.push({ tempParsedLines.push({
type: 'gap', type: 'gap',
time: totalSeconds, time: totalSeconds,
originalTime: match[0] originalTime: match[0],
}) })
} }
} }
@ -207,14 +222,20 @@ function parseLyrics(lrcText: string, minGapDuration = 5): (LyricsLine | GapLine
tempParsedLines.sort((a, b) => a.time - b.time) tempParsedLines.sort((a, b) => a.time - b.time)
const finalLines: (LyricsLine | GapLine)[] = [] const finalLines: (LyricsLine | GapLine)[] = []
const lyricLines = tempParsedLines.filter(line => line.type === 'lyric') as LyricsLine[] const lyricLines = tempParsedLines.filter(
const gapLines = tempParsedLines.filter(line => line.type === 'gap') as GapLine[] (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++) { for (let i = 0; i < gapLines.length; i++) {
const gapLine = gapLines[i] const gapLine = gapLines[i]
const nextLyricLine = lyricLines.find(lyric => lyric.time > gapLine.time) const nextLyricLine = lyricLines.find((lyric) => lyric.time > gapLine.time)
if (nextLyricLine) { if (nextLyricLine) {
const duration = nextLyricLine.time - gapLine.time const duration = nextLyricLine.time - gapLine.time
@ -233,16 +254,20 @@ function parseLyrics(lrcText: string, minGapDuration = 5): (LyricsLine | GapLine
type: 'lyric', type: 'lyric',
time: 0, time: 0,
text: '', text: '',
originalTime: '[00:00]' originalTime: '[00:00]',
}) })
return sortedLines return sortedLines
} }
// //
function findCurrentLineIndex(time: number): number { function findCurrentLineIndex(time: number): number {
if (parsedLyrics.value.length === 0) {return -1} if (parsedLyrics.value.length === 0) {
return -1
}
// 0 // 0
if (time < parsedLyrics.value[1]?.time) {return 0} if (time < parsedLyrics.value[1]?.time) {
return 0
}
let index = 0 let index = 0
for (let i = 1; i < parsedLyrics.value.length; i++) { for (let i = 1; i < parsedLyrics.value.length; i++) {
if (time >= parsedLyrics.value[i].time) { if (time >= parsedLyrics.value[i].time) {
@ -256,7 +281,13 @@ function findCurrentLineIndex(time: number): number {
// 使 GSAP // 使 GSAP
function scrollToLine(lineIndex: number, smooth = true) { 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 container = lyricsContainer.value
const wrapper = lyricsWrapper.value const wrapper = lyricsWrapper.value
@ -280,10 +311,10 @@ function scrollToLine(lineIndex: number, smooth = true) {
scrollTween = gsap.to(wrapper, { scrollTween = gsap.to(wrapper, {
y: targetY, y: targetY,
duration: 0.8, duration: 0.8,
ease: "power2.out", ease: 'power2.out',
onComplete: () => { onComplete: () => {
scrollTween = null scrollTween = null
} },
}) })
} else { } else {
gsap.set(wrapper, { y: targetY }) gsap.set(wrapper, { y: targetY })
@ -292,7 +323,9 @@ function scrollToLine(lineIndex: number, smooth = true) {
// //
function highlightCurrentLine(lineIndex: number) { function highlightCurrentLine(lineIndex: number) {
if (!lineRefs.value[lineIndex]) {return} if (!lineRefs.value[lineIndex]) {
return
}
const lineElement = lineRefs.value[lineIndex] const lineElement = lineRefs.value[lineIndex]
@ -308,7 +341,7 @@ function highlightCurrentLine(lineIndex: number) {
scale: 1, scale: 1,
opacity: index < lineIndex ? 0.6 : 0.4, opacity: index < lineIndex ? 0.6 : 0.4,
duration: 0.3, duration: 0.3,
ease: "power2.out" ease: 'power2.out',
}) })
} }
}) })
@ -318,10 +351,10 @@ function highlightCurrentLine(lineIndex: number) {
scale: 1.05, scale: 1.05,
opacity: 1, opacity: 1,
duration: 0.2, duration: 0.2,
ease: "back.out(1.7)", ease: 'back.out(1.7)',
onComplete: () => { onComplete: () => {
highlightTween = null highlightTween = null
} },
}) })
} }
@ -330,7 +363,9 @@ function highlightCurrentLine(lineIndex: number) {
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
event.preventDefault() event.preventDefault()
if (!lyricsWrapper.value || !lyricsContainer.value) {return} if (!lyricsWrapper.value || !lyricsContainer.value) {
return
}
userScrolling.value = true userScrolling.value = true
autoScroll.value = false autoScroll.value = false
@ -339,7 +374,7 @@ function handleWheel(event: WheelEvent) {
scrollTween.kill() scrollTween.kill()
} }
const currentY = gsap.getProperty(lyricsWrapper.value, "y") as number const currentY = gsap.getProperty(lyricsWrapper.value, 'y') as number
const newY = currentY - event.deltaY * 0.5 const newY = currentY - event.deltaY * 0.5
// //
@ -352,7 +387,7 @@ function handleWheel(event: WheelEvent) {
gsap.to(lyricsWrapper.value, { gsap.to(lyricsWrapper.value, {
y: limitedY, y: limitedY,
duration: 0.1, duration: 0.1,
ease: "power2.out" ease: 'power2.out',
}) })
if (userScrollTimeout) { if (userScrollTimeout) {
@ -383,15 +418,16 @@ function handleLineClick(line: LyricsLine | GapLine, index: number) {
// //
if (lineRefs.value[index]) { if (lineRefs.value[index]) {
gsap.fromTo(lineRefs.value[index], gsap.fromTo(
lineRefs.value[index],
{ scale: 1 }, { scale: 1 },
{ {
scale: 1.1, scale: 1.1,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: 'power2.inOut',
} },
) )
} }
} }
@ -403,15 +439,16 @@ function toggleAutoScroll() {
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[0], gsap.fromTo(
controlPanel.value.children[0],
{ scale: 1 }, { scale: 1 },
{ {
scale: 0.95, scale: 0.95,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: 'power2.inOut',
} },
) )
} }
@ -424,17 +461,23 @@ function toggleAutoScroll() {
// //
function resetScroll() { function resetScroll() {
if (!lyricsWrapper.value) {return} if (!lyricsWrapper.value) {
return
}
// //
if (scrollTween) {scrollTween.kill()} if (scrollTween) {
if (highlightTween) {highlightTween.kill()} scrollTween.kill()
}
if (highlightTween) {
highlightTween.kill()
}
// //
gsap.to(lyricsWrapper.value, { gsap.to(lyricsWrapper.value, {
y: 0, y: 0,
duration: 0.3, duration: 0.3,
ease: "power2.out" ease: 'power2.out',
}) })
autoScroll.value = true autoScroll.value = true
@ -442,15 +485,16 @@ function resetScroll() {
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value.children[1], gsap.fromTo(
controlPanel.value.children[1],
{ scale: 1 }, { scale: 1 },
{ {
scale: 0.95, scale: 0.95,
duration: 0.1, duration: 0.1,
yoyo: true, yoyo: true,
repeat: 1, repeat: 1,
ease: "power2.inOut" ease: 'power2.inOut',
} },
) )
} }
@ -466,7 +510,9 @@ function resetScroll() {
function getGapDotOpacities(line: GapLine) { function getGapDotOpacities(line: GapLine) {
// gap // gap
const duration = line.duration ?? 0 const duration = line.duration ?? 0
if (duration <= 0) {return [0.3, 0.3, 0.3]} if (duration <= 0) {
return [0.3, 0.3, 0.3]
}
// //
const now = playStore.progress.currentTime const now = playStore.progress.currentTime
// gap // gap
@ -477,11 +523,15 @@ function getGapDotOpacities(line: GapLine) {
// //
const thresholds = [1 / 4, 2 / 4, 3 / 4] const thresholds = [1 / 4, 2 / 4, 3 / 4]
// 0.3 1 // 0.3 1
return thresholds.map(t => 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(() => playStore.progress.currentTime, (time) => { watch(
() => playStore.progress.currentTime,
(time) => {
const newIndex = findCurrentLineIndex(time) const newIndex = findCurrentLineIndex(time)
if (newIndex !== currentLineIndex.value && newIndex >= 0) { if (newIndex !== currentLineIndex.value && newIndex >= 0) {
@ -497,27 +547,35 @@ watch(() => playStore.progress.currentTime, (time) => {
}) })
} }
} }
}) },
)
// //
watch(() => props.lrcSrc, async (newSrc) => { watch(
() => props.lrcSrc,
async (newSrc) => {
console.log('Loading new lyrics from:', newSrc) console.log('Loading new lyrics from:', newSrc)
// //
currentLineIndex.value = -1 currentLineIndex.value = -1
lineRefs.value = [] lineRefs.value = []
// //
if (scrollTween) {scrollTween.kill()} if (scrollTween) {
if (highlightTween) {highlightTween.kill()} scrollTween.kill()
}
if (highlightTween) {
highlightTween.kill()
}
if (newSrc) { if (newSrc) {
loading.value = true loading.value = true
// //
if (loadingIndicator.value) { if (loadingIndicator.value) {
gsap.fromTo(loadingIndicator.value, gsap.fromTo(
loadingIndicator.value,
{ opacity: 0, scale: 0.8 }, { opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" } { opacity: 1, scale: 1, duration: 0.3, ease: 'back.out(1.7)' },
) )
} }
@ -533,7 +591,6 @@ watch(() => props.lrcSrc, async (newSrc) => {
if (lyricsWrapper.value) { if (lyricsWrapper.value) {
gsap.set(lyricsWrapper.value, { y: 0 }) gsap.set(lyricsWrapper.value, { y: 0 })
} }
} catch (error) { } catch (error) {
console.error('Failed to load lyrics:', error) console.error('Failed to load lyrics:', error)
parsedLyrics.value = [] parsedLyrics.value = []
@ -548,8 +605,9 @@ watch(() => props.lrcSrc, async (newSrc) => {
gsap.set(lyricsWrapper.value, { y: 0 }) gsap.set(lyricsWrapper.value, { y: 0 })
} }
} }
}, { immediate: true }) },
{ immediate: true },
)
// //
let handleVisibilityChange: (() => void) | null = null let handleVisibilityChange: (() => void) | null = null
@ -559,16 +617,28 @@ function setupPageFocusHandlers() {
handleVisibilityChange = () => { handleVisibilityChange = () => {
if (document.hidden) { if (document.hidden) {
// //
if (scrollTween) {scrollTween.pause()} if (scrollTween) {
if (highlightTween) {highlightTween.pause()} scrollTween.pause()
}
if (highlightTween) {
highlightTween.pause()
}
} else { } else {
// //
if (scrollTween?.paused()) {scrollTween.resume()} if (scrollTween?.paused()) {
if (highlightTween?.paused()) {highlightTween.resume()} scrollTween.resume()
}
if (highlightTween?.paused()) {
highlightTween.resume()
}
// //
nextTick(() => { nextTick(() => {
if (currentLineIndex.value >= 0 && autoScroll.value && !userScrolling.value) { if (
currentLineIndex.value >= 0 &&
autoScroll.value &&
!userScrolling.value
) {
scrollToLine(currentLineIndex.value, false) // 使 scrollToLine(currentLineIndex.value, false) // 使
} }
}) })
@ -585,9 +655,10 @@ onMounted(() => {
// //
if (controlPanel.value) { if (controlPanel.value) {
gsap.fromTo(controlPanel.value, gsap.fromTo(
controlPanel.value,
{ opacity: 0, x: 20 }, { opacity: 0, x: 20 },
{ opacity: 0, x: 0, duration: 0.2, ease: "power2.out", delay: 0.2 } { opacity: 0, x: 0, duration: 0.2, ease: 'power2.out', delay: 0.2 },
) )
} }
@ -595,15 +666,16 @@ onMounted(() => {
nextTick(() => { nextTick(() => {
lineRefs.value.forEach((el, index) => { lineRefs.value.forEach((el, index) => {
if (el) { if (el) {
gsap.fromTo(el, gsap.fromTo(
el,
{ opacity: 0, y: 30 }, { opacity: 0, y: 30 },
{ {
opacity: 1, opacity: 1,
y: 0, y: 0,
duration: 0.2, duration: 0.2,
ease: "power2.out", ease: 'power2.out',
delay: index * 0.1 delay: index * 0.1,
} },
) )
} }
}) })
@ -612,9 +684,15 @@ onMounted(() => {
// //
onUnmounted(() => { onUnmounted(() => {
if (scrollTween) {scrollTween.kill()} if (scrollTween) {
if (highlightTween) {highlightTween.kill()} scrollTween.kill()
if (userScrollTimeout) {clearTimeout(userScrollTimeout)} }
if (highlightTween) {
highlightTween.kill()
}
if (userScrollTimeout) {
clearTimeout(userScrollTimeout)
}
// //
if (handleVisibilityChange) { if (handleVisibilityChange) {
@ -627,7 +705,10 @@ defineExpose({
scrollToLine, scrollToLine,
toggleAutoScroll, toggleAutoScroll,
resetScroll, resetScroll,
getCurrentLine: () => currentLineIndex.value >= 0 ? parsedLyrics.value[currentLineIndex.value] : null getCurrentLine: () =>
currentLineIndex.value >= 0
? parsedLyrics.value[currentLineIndex.value]
: null,
}) })
</script> </script>

View File

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

View File

@ -9,10 +9,10 @@ import StarEmptyIcon from '../assets/icons/starempty.vue'
import StarFilledIcon from '../assets/icons/starfilled.vue' import StarFilledIcon from '../assets/icons/starfilled.vue'
const props = defineProps<{ const props = defineProps<{
album?: Album, album?: Album
track: Song, track: Song
index: number, index: number
playfrom: (index: number) => void, playfrom: (index: number) => void
}>() }>()
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>

View File

@ -13,15 +13,14 @@ import Library from './pages/Library.vue'
const routes = [ const routes = [
{ path: '/', component: HomePage }, { path: '/', component: HomePage },
{ path: '/playroom', component: Playroom }, { path: '/playroom', component: Playroom },
{ path: '/library', component: Library } { path: '/library', component: Library },
] ]
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes routes,
}) })
const pinia = createPinia() const pinia = createPinia()
createApp(App).use(router).use(pinia).use(ToastPlugin).mount('#app') createApp(App).use(router).use(pinia).use(ToastPlugin).mount('#app')

View File

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

View File

@ -15,19 +15,27 @@ const currentList = ref<'favourites' | number>('favourites')
function playTheList(list: 'favourites' | number, playFrom = 0) { function playTheList(list: 'favourites' | number, playFrom = 0) {
let actualPlayFrom = playFrom let actualPlayFrom = playFrom
if (playFrom < 0 || playFrom >= favourites.favouritesCount) { actualPlayFrom = 0 } if (playFrom < 0 || playFrom >= favourites.favouritesCount) {
actualPlayFrom = 0
}
if (playQueueStore.queueReplaceLock) { if (playQueueStore.queueReplaceLock) {
if (!confirm("当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?")) { return } if (
!confirm(
'当前操作会将你的播放队列清空、放入这张歌单所有曲目,并从头播放。继续吗?',
)
) {
return
}
playQueueStore.queueReplaceLock = false playQueueStore.queueReplaceLock = false
} }
if (list === 'favourites') { if (list === 'favourites') {
if (favourites.favouritesCount === 0) return if (favourites.favouritesCount === 0) return
let newPlayQueue = favourites.favourites.map(item => ({ let newPlayQueue = favourites.favourites.map((item) => ({
song: item.song, song: item.song,
album: item.album album: item.album,
})) }))
// playQueueStore.list = newPlayQueue.slice().reverse() // playQueueStore.list = newPlayQueue.slice().reverse()
// playQueueStore.currentIndex = playFrom // playQueueStore.currentIndex = playFrom
@ -49,7 +57,6 @@ function shuffle(list: 'favourites' | number) {
// playQueueStore.isBuffering = true // playQueueStore.isBuffering = true
// }, 100) // }, 100)
} }
</script> </script>
<template> <template>

View File

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePlayStore } from '../stores/usePlayStore' import { usePlayStore } from '../stores/usePlayStore'
import { artistsOrganize } from '../utils'
import gsap from 'gsap' import gsap from 'gsap'
import { Draggable } from "gsap/Draggable" import { Draggable } from 'gsap/Draggable'
import { onMounted, onUnmounted, nextTick } from 'vue' import { onMounted, onUnmounted, nextTick } from 'vue'
import { useTemplateRef } from 'vue' import { useTemplateRef } from 'vue'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
@ -68,9 +67,10 @@ onMounted(async () => {
onDrag: function () { onDrag: function () {
const thumbPosition = this.x const thumbPosition = this.x
const containerWidth = progressBarContainer.value?.clientWidth || 0 const containerWidth = progressBarContainer.value?.clientWidth || 0
const newTime = (thumbPosition / containerWidth) * playStore.progress.duration const newTime =
(thumbPosition / containerWidth) * playStore.progress.duration
playStore.updateCurrentTime(newTime) playStore.updateCurrentTime(newTime)
} },
}) })
// DOM // DOM
@ -93,23 +93,30 @@ onMounted(async () => {
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>
function timeFormatter(time: number) { function timeFormatter(time: number) {
const timeInSeconds = Math.floor(time) const timeInSeconds = Math.floor(time)
if (timeInSeconds < 0) { return '-:--' } if (timeInSeconds < 0) {
return '-:--'
}
const minutes = Math.floor(timeInSeconds / 60) const minutes = Math.floor(timeInSeconds / 60)
const seconds = Math.floor(timeInSeconds % 60) const seconds = Math.floor(timeInSeconds % 60)
if (Number.isNaN(minutes) || Number.isNaN(seconds)) { return '-:--' } if (Number.isNaN(minutes) || Number.isNaN(seconds)) {
return '-:--'
}
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}` return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
} }
// //
watch(() => playStore.progress.currentTime, () => { watch(
() => playStore.progress.currentTime,
() => {
thumbUpdate() thumbUpdate()
}) },
)
function thumbUpdate() { function thumbUpdate() {
const progress = playStore.progress.percentage const progress = playStore.progress.percentage
const containerWidth = progressBarContainer.value?.clientWidth || 0 const containerWidth = progressBarContainer.value?.clientWidth || 0
const thumbWidth = progressBarThumb.value?.clientWidth || 0 const thumbWidth = progressBarThumb.value?.clientWidth || 0
const newPosition = (containerWidth - thumbWidth) * progress const newPosition = ((containerWidth - thumbWidth) * progress) / 100
gsap.to(progressBarThumb.value, { x: newPosition, duration: 0.1 }) gsap.to(progressBarThumb.value, { x: newPosition, duration: 0.1 })
} }
@ -156,7 +163,10 @@ function createVolumeDraggable() {
const containerWidth = volumeSliderContainer.value?.clientWidth || 0 const containerWidth = volumeSliderContainer.value?.clientWidth || 0
const thumbWidth = volumeSliderThumb.value?.clientWidth || 0 const thumbWidth = volumeSliderThumb.value?.clientWidth || 0
// 0-1 // 0-1
const newVolume = Math.max(0, Math.min(1, thumbPosition / (containerWidth - thumbWidth))) const newVolume = Math.max(
0,
Math.min(1, thumbPosition / (containerWidth - thumbWidth)),
)
volume.value = newVolume volume.value = newVolume
updateAudioVolume() updateAudioVolume()
// localStorage // localStorage
@ -165,7 +175,7 @@ function createVolumeDraggable() {
onDragEnd: () => { onDragEnd: () => {
// //
localStorage.setItem('audioVolume', volume.value.toString()) localStorage.setItem('audioVolume', volume.value.toString())
} },
}) })
console.log('Volume draggable created successfully') console.log('Volume draggable created successfully')
@ -182,58 +192,64 @@ function updateAudioVolume() {
// biome-ignore lint/correctness/noUnusedVariables: used in <template> // biome-ignore lint/correctness/noUnusedVariables: used in <template>
function formatDetector() { function formatDetector() {
const format = playStore.currentTrack?.url?.split('.').pop() const format = playStore.currentTrack?.url?.split('.').pop()
if (format === 'mp3') { return 'MP3' } if (format === 'mp3') {
if (format === 'flac') { return 'FLAC' } return 'MP3'
if (format === 'm4a') { return 'M4A' } }
if (format === 'ape') { return 'APE' } if (format === 'flac') {
if (format === 'wav') { return 'WAV' } return 'FLAC'
}
if (format === 'm4a') {
return 'M4A'
}
if (format === 'ape') {
return 'APE'
}
if (format === 'wav') {
return 'WAV'
}
return '未知格式' return '未知格式'
} }
function setupEntranceAnimations() { function setupEntranceAnimations() {
if (controllerRef.value) { if (controllerRef.value) {
gsap.fromTo(controllerRef.value.children, gsap.fromTo(
controllerRef.value.children,
{ opacity: 0, y: 30, scale: 0.95 }, { opacity: 0, y: 30, scale: 0.95 },
{ {
opacity: 1, y: 0, scale: 1, opacity: 1,
duration: 0.6, ease: "power2.out", stagger: 0.1 y: 0,
} scale: 1,
duration: 0.6,
ease: 'power2.out',
stagger: 0.1,
},
) )
} }
if (lyricsSection.value) { if (lyricsSection.value) {
gsap.fromTo(lyricsSection.value, gsap.fromTo(
lyricsSection.value,
{ opacity: 0, x: 50 }, { opacity: 0, x: 50 },
{ opacity: 1, x: 0, duration: 0.8, ease: "power2.out", delay: 0.3 } { opacity: 1, x: 0, duration: 0.8, ease: 'power2.out', delay: 0.3 },
) )
} }
} }
function handlePlayPause() { function handlePlayPause() {
if (playButton.value) { playStore.togglePlay()
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
}
} }
function toggleShuffle() { function toggleShuffle() {
playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle // playQueueStore.playMode.shuffle = !playQueueStore.playMode.shuffle
playQueueStore.shuffleCurrent = false // playQueueStore.shuffleCurrent = false
} }
function toggleRepeat() { function toggleRepeat() {
switch (playQueueStore.playMode.repeat) { // switch (playQueueStore.playMode.repeat) {
case 'off': playQueueStore.playMode.repeat = 'all'; break // case 'off': playQueueStore.playMode.repeat = 'all'; break
case 'all': playQueueStore.playMode.repeat = 'single'; break // case 'all': playQueueStore.playMode.repeat = 'single'; break
case 'single': playQueueStore.playMode.repeat = 'off'; break // case 'single': playQueueStore.playMode.repeat = 'off'; break
} // }
} }
function makePlayQueueListPresent() { function makePlayQueueListPresent() {
@ -244,15 +260,26 @@ function makePlayQueueListPresent() {
const tl = gsap.timeline() const tl = gsap.timeline()
tl.to(playQueueDialogContainer.value, { tl.to(playQueueDialogContainer.value, {
backgroundColor: '#17171780', duration: 0.3, ease: 'power2.out' backgroundColor: '#17171780',
}).to(playQueueDialog.value, { duration: 0.3,
x: 0, duration: 0.4, ease: 'power3.out' ease: 'power2.out',
}, '<0.1') }).to(
playQueueDialog.value,
{
x: 0,
duration: 0.4,
ease: 'power3.out',
},
'<0.1',
)
if (playQueueDialog.value.children.length > 0) { if (playQueueDialog.value.children.length > 0) {
tl.fromTo(playQueueDialog.value.children, tl.fromTo(
playQueueDialog.value.children,
{ opacity: 0, x: -20 }, { 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',
)
} }
}) })
} }
@ -270,23 +297,40 @@ function makePlayQueueListDismiss() {
gsap.set(playQueueDialog.value, { x: -384 }) gsap.set(playQueueDialog.value, { x: -384 })
} }
if (playQueueDialogContainer.value) { if (playQueueDialogContainer.value) {
gsap.set(playQueueDialogContainer.value, { backgroundColor: 'transparent' }) gsap.set(playQueueDialogContainer.value, {
} backgroundColor: 'transparent',
})
} }
},
}) })
if (playQueueDialog.value.children.length > 0) { if (playQueueDialog.value.children.length > 0) {
tl.to(playQueueDialog.value.children, { tl.to(playQueueDialog.value.children, {
opacity: 0, 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, { tl.to(
x: -384, duration: 0.3, ease: 'power2.in' playQueueDialog.value,
}, playQueueDialog.value.children.length > 0 ? '<0.1' : '0') {
.to(playQueueDialogContainer.value, { x: -384,
backgroundColor: 'transparent', duration: 0.2, ease: 'power2.in' 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() { function getCurrentTrack() {
@ -299,15 +343,23 @@ function toggleMoreOptions() {
nextTick(() => { nextTick(() => {
if (moreOptionsDialog.value) { if (moreOptionsDialog.value) {
const tl = gsap.timeline() const tl = gsap.timeline()
tl.fromTo(moreOptionsDialog.value, tl.fromTo(
moreOptionsDialog.value,
{ opacity: 0, scale: 0.9, y: 10 }, { opacity: 0, scale: 0.9, y: 10 },
{ opacity: 1, scale: 1, y: 0, duration: 0.2, ease: "power2.out" } { opacity: 1, scale: 1, y: 0, duration: 0.2, ease: 'power2.out' },
) )
if (moreOptionsDialog.value.children[0]?.children) { if (moreOptionsDialog.value.children[0]?.children) {
tl.fromTo(moreOptionsDialog.value.children[0].children, tl.fromTo(
moreOptionsDialog.value.children[0].children,
{ opacity: 0, x: -10 }, { opacity: 0, x: -10 },
{ opacity: 1, x: 0, duration: 0.15, ease: "power2.out", stagger: 0.05 }, {
"<0.1" opacity: 1,
x: 0,
duration: 0.15,
ease: 'power2.out',
stagger: 0.05,
},
'<0.1',
) )
} }
} }
@ -317,16 +369,21 @@ function toggleMoreOptions() {
const tl = gsap.timeline({ const tl = gsap.timeline({
onComplete: () => { onComplete: () => {
showMoreOptions.value = false showMoreOptions.value = false
} },
}) })
if (moreOptionsDialog.value.children[0]?.children) { if (moreOptionsDialog.value.children[0]?.children) {
tl.to(moreOptionsDialog.value.children[0].children, tl.to(moreOptionsDialog.value.children[0].children, {
{ opacity: 0, x: -10, duration: 0.1, ease: "power2.in", stagger: 0.02 } opacity: 0,
) x: -10,
duration: 0.1,
ease: 'power2.in',
stagger: 0.02,
})
} }
tl.to(moreOptionsDialog.value, tl.to(
{ opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: "power2.in" }, moreOptionsDialog.value,
moreOptionsDialog.value.children[0]?.children ? "<0.05" : "0" { opacity: 0, scale: 0.9, y: 10, duration: 0.15, ease: 'power2.in' },
moreOptionsDialog.value.children[0]?.children ? '<0.05' : '0',
) )
} else { } else {
showMoreOptions.value = false showMoreOptions.value = false
@ -335,71 +392,96 @@ function toggleMoreOptions() {
} }
// TODO: lyrics // TODO: lyrics
// watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newValue, oldValue) => { watch(
// if (!getCurrentTrack()) { return } () => [preferences.presentLyrics, getCurrentTrack()?.extra?.lyric],
(newValue, oldValue) => {
if (!getCurrentTrack()) {
return
}
// const [showLyrics, hasLyricUrl] = newValue const [showLyrics, hasLyricUrl] = newValue
// const [prevShowLyrics, _prevHasLyricUrl] = oldValue || [false, null] const [prevShowLyrics, _prevHasLyricUrl] = oldValue || [false, null]
// // Show lyrics when both conditions are met // Show lyrics when both conditions are met
// if (showLyrics && hasLyricUrl) { if (showLyrics && hasLyricUrl) {
// presentLyrics.value = true presentLyrics.value = true
// nextTick(() => { nextTick(() => {
// const tl = gsap.timeline() const tl = gsap.timeline()
// tl.from(controllerRef.value, { tl.from(controllerRef.value, {
// marginRight: '-40rem', marginRight: '-40rem',
// }).fromTo(lyricsSection.value, }).fromTo(
// { opacity: 0, x: 50, scale: 0.95 }, lyricsSection.value,
// { opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" }, { opacity: 0, x: 50, scale: 0.95 },
// "-=0.3" { 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) { // Hide lyrics with different animations based on reason
// let animationConfig else if (presentLyrics.value) {
let animationConfig: {
opacity: number
x: number
scale?: number
duration: number
ease: string
}
// // If lyrics were toggled off // If lyrics were toggled off
// if (prevShowLyrics && !showLyrics) { if (prevShowLyrics && !showLyrics) {
// animationConfig = { animationConfig = {
// opacity: 0, x: -50, scale: 0.95, opacity: 0,
// duration: 0.3, ease: "power2.in" x: -50,
// } scale: 0.95,
// } duration: 0.3,
// // If no lyrics available (song changed) ease: 'power2.in',
// else if (!hasLyricUrl) { }
// animationConfig = { }
// opacity: 0, y: -20, scale: 0.98, // If no lyrics available (song changed)
// duration: 0.3, ease: "power1.in" else if (!hasLyricUrl) {
// } animationConfig = {
// } opacity: 0,
// // Default animation y: -20,
// else { scale: 0.98,
// animationConfig = { duration: 0.3,
// opacity: 0, x: -50, ease: 'power1.in',
// duration: 0.3, ease: "power2.in" }
// } }
// } // Default animation
else {
animationConfig = {
opacity: 0,
x: -50,
duration: 0.3,
ease: 'power2.in',
}
}
// const tl = gsap.timeline({ const tl = gsap.timeline({
// onComplete: () => { onComplete: () => {
// presentLyrics.value = false presentLyrics.value = false
// } },
// }) })
// tl.to(controllerRef.value, { tl.to(controllerRef.value, {
// marginLeft: '44rem', marginLeft: '44rem',
// duration: 0.3, ease: "power2.out" duration: 0.3,
// }) ease: 'power2.out',
// .to(lyricsSection.value, animationConfig, '<') })
// .set(lyricsSection.value, { .to(lyricsSection.value, animationConfig, '<')
// opacity: 1, x: 0, y: 0, scale: 1 // Reset for next time .set(lyricsSection.value, {
// }) opacity: 1,
// .set(controllerRef.value, { x: 0,
// marginLeft: '0rem' // Reset for next time y: 0,
// }) scale: 1, // Reset for next time
// } })
// }, { immediate: true }) .set(controllerRef.value, {
marginLeft: '0rem', // Reset for next time
})
}
},
{ immediate: true },
)
// //
let handleVisibilityChange: (() => void) | null = null let handleVisibilityChange: (() => void) | null = null
@ -445,7 +527,9 @@ function setupPageFocusHandlers() {
// //
function resyncLyricsState() { function resyncLyricsState() {
const currentTrack = getCurrentTrack() const currentTrack = getCurrentTrack()
if (!currentTrack) { return } if (!currentTrack) {
return
}
console.log('[Playroom] 重新同步歌词状态') console.log('[Playroom] 重新同步歌词状态')
@ -453,7 +537,7 @@ function resyncLyricsState() {
if (controllerRef.value) { if (controllerRef.value) {
gsap.set(controllerRef.value, { gsap.set(controllerRef.value, {
marginLeft: '0rem', marginLeft: '0rem',
marginRight: '0rem' marginRight: '0rem',
}) })
} }
@ -462,15 +546,19 @@ function resyncLyricsState() {
opacity: 1, opacity: 1,
x: 0, x: 0,
y: 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) { if (shouldShowLyrics !== presentLyrics.value) {
console.log(`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`) console.log(
`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`,
)
// //
presentLyrics.value = shouldShowLyrics presentLyrics.value = shouldShowLyrics
@ -482,11 +570,12 @@ function resyncLyricsState() {
tl.from(controllerRef.value, { tl.from(controllerRef.value, {
marginRight: '-40rem', marginRight: '-40rem',
duration: 0.4, duration: 0.4,
ease: "power2.out" ease: 'power2.out',
}).fromTo(lyricsSection.value, }).fromTo(
lyricsSection.value,
{ opacity: 0, x: 50, scale: 0.95 }, { opacity: 0, x: 50, scale: 0.95 },
{ opacity: 1, x: 0, scale: 1, duration: 0.5, ease: "power2.out" }, { opacity: 1, x: 0, scale: 1, duration: 0.5, ease: 'power2.out' },
"-=0.2" '-=0.2',
) )
}) })
} }
@ -494,31 +583,39 @@ function resyncLyricsState() {
} }
// New: Watch for track changes and animate // New: Watch for track changes and animate
watch(() => playStore.currentTrack, () => { watch(
() => playStore.currentTrack,
() => {
if (albumCover.value) { if (albumCover.value) {
gsap.to(albumCover.value, { gsap.to(albumCover.value, {
scale: 0.95, opacity: 0.7, duration: 0.2, scale: 0.95,
ease: "power2.inOut", yoyo: true, repeat: 1 opacity: 0.7,
duration: 0.2,
ease: 'power2.inOut',
yoyo: true,
repeat: 1,
}) })
} }
if (songInfo.value) { if (songInfo.value) {
gsap.fromTo(songInfo.value, gsap.fromTo(
songInfo.value,
{ opacity: 0, y: 10 }, { opacity: 0, y: 10 },
{ opacity: 1, y: 0, duration: 0.4, ease: "power2.out", delay: 0.3 } { opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', delay: 0.3 },
) )
} }
}) },
)
</script> </script>
<template> <template>
<div v-if="getCurrentTrack() !== null"> <div v-if="getCurrentTrack() !== null">
<!-- Background remains unchanged --> <!-- Background remains unchanged -->
<!-- <div class="z-0 absolute top-0 left-0 w-screen h-screen overflow-hidden" <div class="z-0 absolute top-0 left-0 w-screen h-screen overflow-hidden"
v-if="getCurrentTrack()?.album?.coverDeUrl"> v-if="getCurrentTrack()?.extra?.background">
<img class="w-full h-full blur-2xl object-cover scale-110" :src="getCurrentTrack()?.album?.coverDeUrl" /> <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 class="w-full h-full absolute top-0 left-0 bg-neutral-900/5" />
</div> --> </div>
<!-- Main content area - new centered flex layout --> <!-- Main content area - new centered flex layout -->
<div class="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none"> <div class="absolute top-0 left-0 flex justify-center h-screen w-screen overflow-y-auto z-10 select-none">
@ -727,7 +824,7 @@ watch(() => playStore.currentTrack, () => {
</button> </button>
<!-- Show tooltip only on hover, with transition --> <!-- Show tooltip only on hover, with transition -->
<transition name="lyrics-tooltip-fade"> <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]"> 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="font-semibold text-white">这首曲目不提供歌词文本</div>
<div class="text-white/60">启用歌词时将会在下一首有歌词的曲目中显示歌词文本</div> <div class="text-white/60">启用歌词时将会在下一首有歌词的曲目中显示歌词文本</div>
@ -781,7 +878,7 @@ watch(() => playStore.currentTrack, () => {
<!-- Lyrics section - full screen height --> <!-- Lyrics section - full screen height -->
<div class="w-[40rem] h-screen" ref="lyricsSection" v-if="presentLyrics"> <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" /> ref="scrollingLyrics" />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from 'pinia'
import { ref, watch, computed } from "vue" import { ref, watch, computed } from 'vue'
// 声明全局类型 // 声明全局类型
declare global { declare global {
@ -21,7 +21,11 @@ export const useFavourites = defineStore('favourites', () => {
const detectAvailableAPIs = () => { const detectAvailableAPIs = () => {
// 检查原生 chrome API // 检查原生 chrome API
try { try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) { if (
typeof chrome !== 'undefined' &&
chrome.storage &&
chrome.storage.local
) {
storageType.value = 'chrome' storageType.value = 'chrome'
return 'chrome' return 'chrome'
} }
@ -31,7 +35,11 @@ export const useFavourites = defineStore('favourites', () => {
// 检查 window.chrome // 检查 window.chrome
try { try {
if (window.chrome && window.chrome.storage && window.chrome.storage.local) { if (
window.chrome &&
window.chrome.storage &&
window.chrome.storage.local
) {
storageType.value = 'chrome' storageType.value = 'chrome'
return 'chrome' return 'chrome'
} }
@ -131,7 +139,8 @@ export const useFavourites = defineStore('favourites', () => {
const normalizeFavourites = (data: any[]): InternalQueueItem[] => { const normalizeFavourites = (data: any[]): InternalQueueItem[] => {
if (!Array.isArray(data)) return [] if (!Array.isArray(data)) return []
return data.map(item => { return data
.map((item) => {
if (!item || !item.song) return null if (!item || !item.song) return null
// 规范化 Song 对象 // 规范化 Song 对象
@ -144,37 +153,48 @@ export const useFavourites = defineStore('favourites', () => {
mvUrl: item.song.mvUrl, mvUrl: item.song.mvUrl,
mvCoverUrl: item.song.mvCoverUrl, mvCoverUrl: item.song.mvCoverUrl,
// 确保 artistes 和 artists 是数组 // 确保 artistes 和 artists 是数组
artistes: Array.isArray(item.song.artistes) ? item.song.artistes : artistes: Array.isArray(item.song.artistes)
typeof item.song.artistes === 'object' ? Object.values(item.song.artistes) : ? item.song.artistes
[], : typeof item.song.artistes === 'object'
artists: Array.isArray(item.song.artists) ? item.song.artists : ? Object.values(item.song.artistes)
typeof item.song.artists === 'object' ? Object.values(item.song.artists) : : [],
[] artists: Array.isArray(item.song.artists)
? item.song.artists
: typeof item.song.artists === 'object'
? Object.values(item.song.artists)
: [],
} }
// 规范化 Album 对象(如果存在) // 规范化 Album 对象(如果存在)
const album = item.album ? { const album = item.album
? {
cid: item.album.cid || '', cid: item.album.cid || '',
name: item.album.name || '', name: item.album.name || '',
intro: item.album.intro, intro: item.album.intro,
belong: item.album.belong, belong: item.album.belong,
coverUrl: item.album.coverUrl || '', coverUrl: item.album.coverUrl || '',
coverDeUrl: item.album.coverDeUrl, coverDeUrl: item.album.coverDeUrl,
artistes: Array.isArray(item.album.artistes) ? item.album.artistes : artistes: Array.isArray(item.album.artistes)
typeof item.album.artistes === 'object' ? Object.values(item.album.artistes) : ? item.album.artistes
[], : typeof item.album.artistes === 'object'
songs: item.album.songs ? Object.values(item.album.artistes)
} : undefined : [],
songs: item.album.songs,
}
: undefined
return { song, album } return { song, album }
}).filter(Boolean) as InternalQueueItem[] })
.filter(Boolean) as InternalQueueItem[]
} }
// 获取收藏列表 // 获取收藏列表
const getFavourites = async () => { const getFavourites = async () => {
const result = await getStoredValue('favourites', defaultFavourites) const result = await getStoredValue('favourites', defaultFavourites)
// 确保返回的是数组并进行数据规范化 // 确保返回的是数组并进行数据规范化
const normalizedResult = Array.isArray(result) ? normalizeFavourites(result) : defaultFavourites const normalizedResult = Array.isArray(result)
? normalizeFavourites(result)
: defaultFavourites
return normalizedResult return normalizedResult
} }
@ -187,7 +207,7 @@ export const useFavourites = defineStore('favourites', () => {
// 检查歌曲是否已收藏 // 检查歌曲是否已收藏
const isFavourite = (songCid: string): boolean => { const isFavourite = (songCid: string): boolean => {
return favourites.value.some(item => item.song.cid === songCid) return favourites.value.some((item) => item.song.cid === songCid)
} }
// 添加到收藏 // 添加到收藏
@ -208,7 +228,9 @@ export const useFavourites = defineStore('favourites', () => {
// 从收藏中移除 // 从收藏中移除
const removeFromFavourites = async (songCid: string) => { 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) { if (index !== -1) {
const removedItem = favourites.value.splice(index, 1)[0] const removedItem = favourites.value.splice(index, 1)[0]
if (isLoaded.value) { if (isLoaded.value) {
@ -265,7 +287,9 @@ export const useFavourites = defineStore('favourites', () => {
// 监听变化并保存(防抖处理) // 监听变化并保存(防抖处理)
let saveTimeout: NodeJS.Timeout | null = null let saveTimeout: NodeJS.Timeout | null = null
watch(favourites, async () => { watch(
favourites,
async () => {
if (isLoaded.value) { if (isLoaded.value) {
// 清除之前的定时器 // 清除之前的定时器
if (saveTimeout) { if (saveTimeout) {
@ -280,14 +304,21 @@ export const useFavourites = defineStore('favourites', () => {
} }
}, 300) }, 300)
} }
}, { deep: true }) },
{ deep: true },
)
// 更新收藏列表中的歌曲信息 // 更新收藏列表中的歌曲信息
const updateSongInFavourites = async (songCid: string, updatedSong: Song) => { 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) { if (index !== -1) {
// 更新歌曲信息,保持其他属性不变 // 更新歌曲信息,保持其他属性不变
favourites.value[index].song = { ...favourites.value[index].song, ...updatedSong } favourites.value[index].song = {
...favourites.value[index].song,
...updatedSong,
}
if (isLoaded.value) { if (isLoaded.value) {
try { try {
await saveFavourites() await saveFavourites()
@ -317,7 +348,6 @@ export const useFavourites = defineStore('favourites', () => {
clearFavourites, clearFavourites,
getStoredValue, getStoredValue,
setStoredValue, setStoredValue,
updateSongInFavourites updateSongInFavourites,
} }
}) })

View File

@ -8,35 +8,47 @@ export const usePlayStore = defineStore('player', () => {
const player = ref(new Player()) const player = ref(new Player())
const queueReplaceLock = ref(false) const queueReplaceLock = ref(false)
const visualizer = ref([0,0,0,0,0,0]) const visualizer = ref([0, 0, 0, 0, 0, 0])
const progress = ref({ const progress = ref({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
percentage: 0 percentage: 0,
}) })
const currentTrack = ref<QueueItem>() const currentTrack = ref<QueueItem>()
const isPlaying = ref(false) const isPlaying = ref(false)
const queue = ref<QueueItem[]>([]) const queue = ref<QueueItem[]>([])
const shuffle = ref(false) const shuffle = ref(false)
const loop = ref<"off" | "entire_queue" | "single_track">("off") const loop = ref<'off' | 'entire_queue' | 'single_track'>('off')
const replaceQueue = (queue: { const replaceQueue = (
queue: {
song: Song song: Song
album: Album | undefined album: Album | undefined
}[]) => { }[],
) => {
const newQueue = [] const newQueue = []
for (const item of queue) { for (const item of queue) {
newQueue.push({ newQueue.push({
url: item.song.sourceUrl ?? "", url: item.song.sourceUrl ?? '',
metadata: { metadata: {
title: item.song.name, title: item.song.name,
artist: artistsOrganize(item.song.artists ?? item.song.artistes ?? []), artist: artistsOrganize(
artwork: [{ item.song.artists ?? item.song.artistes ?? [],
src: item.album?.coverUrl ?? "", ),
sizes: "500x500", artwork: [
type: ((item.album?.coverUrl ?? "").split(".").at(-1) === "jpg" ? 'image/jpeg' : 'image/png') as "image/jpeg" | "image/png" {
}] 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) player.value.replaceQueue(newQueue)
@ -51,40 +63,48 @@ export const usePlayStore = defineStore('player', () => {
album: Album | undefined album: Album | undefined
}) => { }) => {
player.value.appendTrack({ player.value.appendTrack({
url: item.song.sourceUrl ?? "", url: item.song.sourceUrl ?? '',
metadata: { metadata: {
title: item.song.name, title: item.song.name,
artist: artistsOrganize(item.song.artistes ?? item.song.artists ?? []), artist: artistsOrganize(item.song.artistes ?? item.song.artists ?? []),
artwork: [{ artwork: [
src: item.album?.coverUrl ?? "", {
sizes: "500x500", src: item.album?.coverUrl ?? '',
type: ((item.album?.coverUrl ?? "").split(".").at(-1) === "jpg" ? 'image/jpeg' : 'image/png') as "image/jpeg" | "image/png" 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 => { player.value.onProgressChange((params) => {
progress.value = params progress.value = params
}) })
player.value.onCurrentPlayingChange(params => { player.value.onCurrentPlayingChange((params) => {
currentTrack.value = params currentTrack.value = params
}) })
player.value.onPlayStateChange(params => { player.value.onPlayStateChange((params) => {
isPlaying.value = params isPlaying.value = params
}) })
player.value.onQueueChange(params => { player.value.onQueueChange((params) => {
queue.value = params queue.value = params
}) })
player.value.onShuffleChange(params => { player.value.onShuffleChange((params) => {
shuffle.value = params shuffle.value = params
}) })
player.value.onLoopChange(params => { player.value.onLoopChange((params) => {
loop.value = params loop.value = params
}) })
@ -109,6 +129,6 @@ export const usePlayStore = defineStore('player', () => {
queue, queue,
shuffle, shuffle,
loop, loop,
replaceQueue replaceQueue,
} }
}) })

View File

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

View File

@ -1,5 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from 'pinia'
import { ref } from "vue" import { ref } from 'vue'
// 声明全局类型 // 声明全局类型
declare global { declare global {
@ -26,7 +26,11 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
const detectAvailableAPIs = () => { const detectAvailableAPIs = () => {
// 检查原生 chrome API // 检查原生 chrome API
try { try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) { if (
typeof chrome !== 'undefined' &&
chrome.storage &&
chrome.storage.sync
) {
storageType.value = 'chrome' storageType.value = 'chrome'
return 'chrome' return 'chrome'
} }
@ -36,7 +40,11 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
// 检查 window.chrome // 检查 window.chrome
try { try {
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) { if (
window.chrome &&
window.chrome.storage &&
window.chrome.storage.sync
) {
storageType.value = 'chrome' storageType.value = 'chrome'
return '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) { if (lastShownVersion !== currentVersion) {
@ -199,6 +210,6 @@ export const useUpdatePopup = defineStore('updatePopup', () => {
getLastShownVersion, getLastShownVersion,
initializeUpdatePopup, initializeUpdatePopup,
getStoredValue, getStoredValue,
setStoredValue setStoredValue,
} }
}) })

View File

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

View File

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

View File

@ -24,7 +24,7 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
midBoost = 1.2, // 提升中音 midBoost = 1.2, // 提升中音
trebleBoost = 1.5, // 提升高音 trebleBoost = 1.5, // 提升高音
threshold = 15, // 响度门槛,低于此值不产生波动 threshold = 15, // 响度门槛,低于此值不产生波动
minHeight = 0 // 最小高度百分比 minHeight = 0, // 最小高度百分比
} = options } = options
console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options) console.log('[AudioVisualizer] 初始化平衡频谱,选项:', options)
@ -65,8 +65,15 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
try { try {
log('开始初始化音频上下文...') log('开始初始化音频上下文...')
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() audioContext = new (
log('AudioContext 创建成功, 状态:', audioContext.state, '采样率:', audioContext.sampleRate) window.AudioContext || (window as any).webkitAudioContext
)()
log(
'AudioContext 创建成功, 状态:',
audioContext.state,
'采样率:',
audioContext.sampleRate,
)
// 如果上下文被暂停,尝试恢复 // 如果上下文被暂停,尝试恢复
if (audioContext.state === 'suspended') { if (audioContext.state === 'suspended') {
@ -98,7 +105,7 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
frequencyBinCount: analyser.frequencyBinCount, frequencyBinCount: analyser.frequencyBinCount,
sampleRate: audioContext.sampleRate, sampleRate: audioContext.sampleRate,
frequencyResolution: audioContext.sampleRate / analyser.fftSize, frequencyResolution: audioContext.sampleRate / analyser.fftSize,
maxDecibels: analyser.maxDecibels maxDecibels: analyser.maxDecibels,
}) })
// 连接音频节点 // 连接音频节点
@ -111,7 +118,6 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
isInitialized.value = true isInitialized.value = true
error.value = null error.value = null
log('✅ 音频可视化器初始化成功') log('✅ 音频可视化器初始化成功')
} catch (err) { } catch (err) {
log('❌ 音频上下文初始化失败:', err) log('❌ 音频上下文初始化失败:', err)
error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}` error.value = `初始化失败: ${err instanceof Error ? err.message : String(err)}`
@ -150,21 +156,29 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
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) // 更新竖杠高度 (0-100)
barHeights.value = enhancedBands.map(value => barHeights.value = enhancedBands.map((value) =>
Math.min(100, Math.max(0, (value / 255) * 100 * sensitivity)) 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[] { function divideFrequencyBandsBalanced(
data: Uint8Array,
bands: number,
sampleRate: number,
): number[] {
const nyquist = sampleRate / 2 const nyquist = sampleRate / 2
const result: number[] = [] const result: number[] = []
@ -175,11 +189,12 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
{ min: 250, max: 800, name: '中低音' }, // 索引 2 { min: 250, max: 800, name: '中低音' }, // 索引 2
{ min: 800, max: 2500, name: '中音' }, // 索引 3 { min: 800, max: 2500, name: '中音' }, // 索引 3
{ min: 2500, max: 6000, name: '中高音' }, // 索引 4 { min: 2500, max: 6000, name: '中高音' }, // 索引 4
{ min: 6000, max: 20000, name: '高音' } // 索引 5 { min: 6000, max: 20000, name: '高音' }, // 索引 5
] ]
for (let i = 0; i < bands; i++) { for (let i = 0; i < bands; i++) {
const range = frequencyRanges[i] || frequencyRanges[frequencyRanges.length - 1] const range =
frequencyRanges[i] || frequencyRanges[frequencyRanges.length - 1]
// 将频率转换为 bin 索引 // 将频率转换为 bin 索引
const startBin = Math.floor((range.min / nyquist) * data.length) const startBin = Math.floor((range.min / nyquist) * data.length)
@ -190,7 +205,9 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
const actualEnd = Math.min(data.length - 1, endBin) const actualEnd = Math.min(data.length - 1, endBin)
if (debug && Math.random() < 0.01) { if (debug && Math.random() < 0.01) {
log(`频段 ${i} (${range.name}): ${range.min}-${range.max}Hz, bins ${actualStart}-${actualEnd}`) log(
`频段 ${i} (${range.name}): ${range.min}-${range.max}Hz, bins ${actualStart}-${actualEnd}`,
)
} }
// 计算该频段的 RMS (均方根) 值,而不是简单平均 // 计算该频段的 RMS (均方根) 值,而不是简单平均
@ -213,7 +230,14 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
// 应用频段特定的增强和门槛 // 应用频段特定的增强和门槛
function applyFrequencyEnhancement(bands: number[]): number[] { function applyFrequencyEnhancement(bands: number[]): number[] {
// 六个频段的增强倍数 // 六个频段的增强倍数
const boosts = [bassBoost, bassBoost, midBoost, midBoost, trebleBoost, trebleBoost] const boosts = [
bassBoost,
bassBoost,
midBoost,
midBoost,
trebleBoost,
trebleBoost,
]
return bands.map((value, index) => { return bands.map((value, index) => {
// 应用响度门槛 // 应用响度门槛
@ -221,7 +245,7 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
if (debug && Math.random() < 0.01) { if (debug && Math.random() < 0.01) {
log(`频段 ${index} 低于门槛: ${Math.round(value)} < ${threshold}`) log(`频段 ${index} 低于门槛: ${Math.round(value)} < ${threshold}`)
} }
return minHeight * 255 / 100 // 返回最小高度对应的值 return (minHeight * 255) / 100 // 返回最小高度对应的值
} }
const boost = boosts[index] || 1 const boost = boosts[index] || 1
@ -252,9 +276,13 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
if (audioElement.readyState >= 2) { if (audioElement.readyState >= 2) {
initAudioContext(audioElement) initAudioContext(audioElement)
} else { } else {
audioElement.addEventListener('loadeddata', () => { audioElement.addEventListener(
'loadeddata',
() => {
initAudioContext(audioElement) initAudioContext(audioElement)
}, { once: true }) },
{ once: true },
)
} }
// 监听播放状态 // 监听播放状态
@ -309,7 +337,7 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
Math.random() * 70 + 15, // 中低音15-85 Math.random() * 70 + 15, // 中低音15-85
Math.random() * 80 + 10, // 中音10-90 Math.random() * 80 + 10, // 中音10-90
Math.random() * 75 + 10, // 中高音10-85 Math.random() * 75 + 10, // 中高音10-85
Math.random() * 65 + 15 // 高音15-80 Math.random() * 65 + 15, // 高音15-80
] ]
testCount++ testCount++
@ -323,7 +351,13 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
} }
// 动态调整增强参数和门槛 // 动态调整增强参数和门槛
function updateEnhancement(bass: number, mid: number, treble: number, newThreshold?: number, newMaxDecibels?: number) { function updateEnhancement(
bass: number,
mid: number,
treble: number,
newThreshold?: number,
newMaxDecibels?: number,
) {
options.bassBoost = bass options.bassBoost = bass
options.midBoost = mid options.midBoost = mid
options.trebleBoost = treble options.trebleBoost = treble
@ -338,7 +372,13 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
log('实时更新 maxDecibels:', newMaxDecibels) log('实时更新 maxDecibels:', newMaxDecibels)
} }
} }
log('更新频段增强:', { bass, mid, treble, threshold: options.threshold, maxDecibels: options.maxDecibels }) log('更新频段增强:', {
bass,
mid,
treble,
threshold: options.threshold,
maxDecibels: options.maxDecibels,
})
} }
// 设置响度门槛 // 设置响度门槛
@ -373,6 +413,6 @@ export function audioVisualizer(options: AudioVisualizerOptions = {}) {
testWithFakeData, testWithFakeData,
updateEnhancement, updateEnhancement,
setThreshold, setThreshold,
setMaxDecibels setMaxDecibels,
} }
} }

View File

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

View File

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

View File

@ -9,7 +9,7 @@ import apis from '../apis'
*/ */
export const checkAndRefreshSongResource = async ( export const checkAndRefreshSongResource = async (
song: Song, song: Song,
updateCallback?: (updatedSong: Song) => void updateCallback?: (updatedSong: Song) => void,
): Promise<Song> => { ): Promise<Song> => {
if (!song.sourceUrl) { if (!song.sourceUrl) {
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name) console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
@ -21,13 +21,13 @@ export const checkAndRefreshSongResource = async (
await axios.head(song.sourceUrl, { await axios.head(song.sourceUrl, {
headers: { headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate', 'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache', Pragma: 'no-cache',
'Expires': '0' Expires: '0',
}, },
params: { params: {
_t: Date.now() // 添加时间戳参数避免缓存 _t: Date.now(), // 添加时间戳参数避免缓存
}, },
timeout: 5000 // 5秒超时 timeout: 5000, // 5秒超时
}) })
// 资源可用,返回原始歌曲 // 资源可用,返回原始歌曲
@ -63,7 +63,7 @@ export const checkAndRefreshSongResource = async (
*/ */
export const checkAndRefreshMultipleSongs = async ( export const checkAndRefreshMultipleSongs = async (
songs: Song[], songs: Song[],
updateCallback?: (updatedSong: Song, originalIndex: number) => void updateCallback?: (updatedSong: Song, originalIndex: number) => void,
): Promise<Song[]> => { ): Promise<Song[]> => {
const results: Song[] = [] const results: Song[] = []
@ -71,7 +71,7 @@ export const checkAndRefreshMultipleSongs = async (
const originalSong = songs[i] const originalSong = songs[i]
const updatedSong = await checkAndRefreshSongResource( const updatedSong = await checkAndRefreshSongResource(
originalSong, originalSong,
updateCallback ? (updated) => updateCallback(updated, i) : undefined updateCallback ? (updated) => updateCallback(updated, i) : undefined,
) )
results.push(updatedSong) results.push(updatedSong)
} }

View File

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