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