Compare commits

..

No commits in common. "main" and "feature/safari_compatibility" have entirely different histories.

19 changed files with 230 additions and 1485 deletions

View File

@ -90,12 +90,12 @@ jobs:
- name: 创建 Safari 扩展项目
run: |
xcrun safari-web-extension-converter dist --project-location safari-extension --app-name "MSR Mod" --bundle-identifier "moe.astrian.ext-msrmod" --swift --no-open
xcrun safari-web-extension-converter dist --project-location safari-extension --app-name "MSR Mod" --bundle-identifier "com.astrian.msrmod" --swift --no-open
- name: 构建 Safari 扩展
run: |
cd "safari-extension/MSR Mod"
xcodebuild -project "MSR Mod.xcodeproj" -scheme "MSR Mod (macOS)" -configuration Release -destination "generic/platform=macOS" build
cd safari-extension
xcodebuild -project "MSR Mod.xcodeproj" -scheme "MSR Mod" -configuration Release -destination "generic/platform=macOS" build
- name: 上传构建工件
uses: actions/upload-artifact@v3

159
CLAUDE.md
View File

@ -1,159 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
MSR Mod is a browser extension that provides an alternate frontend for Monster Siren Records (monster-siren.hypergryph.com). It's built with Vue 3, TypeScript, and Tailwind CSS, designed to work as both Chrome and Firefox extensions.
## Common Commands
### Development
```bash
npm run dev # Start development server with Vite
npm run dev:refresh # Build and refresh dist folder for extension development
npm i # Install dependencies
```
### Building
```bash
npm run build:chrome # Build for Chrome/Chromium browsers
npm run build:firefox # Build for Firefox
npm run build:safari # Build for Safari (uses background.html)
npm run build # Default build (Chrome)
```
### Code Quality
```bash
npm run lint # Format code with Biome
npm run quality-check # Run Biome CI checks
npm run qc # Alias for quality-check
```
### Extension Development Workflow
1. Run `npm run dev:refresh` to build initial dist folder
2. Load the `dist` folder as an unpacked extension in browser
3. Use `npm run dev` for hot-reload development
4. Use `npm run build:watch` for continuous builds
## Architecture
### Core Technologies
- **Vue 3** with Composition API and `<script setup>` syntax
- **TypeScript** for type safety
- **Pinia** for state management
- **Vue Router** with hash history for extension compatibility
- **Tailwind CSS v4** for styling
- **GSAP** for animations
- **Axios** for API communication
### Browser Extension Structure
- **Manifest V3** with platform-specific builds
- **Content Scripts** inject the frontend on monster-siren.hypergryph.com
- **Background Service Worker** handles extension lifecycle
- **Cross-platform compatibility** via prebuild scripts
### State Management (Pinia Stores)
- **usePlayQueueStore**: Music playback queue, shuffle/repeat modes, audio preloading
- **useFavourites**: User favorites with cross-platform storage (Chrome storage API/localStorage)
- **usePreferences**: User settings and preferences
### Key Components
- **Player**: Main audio player with preloading and resource validation
- **Playroom**: Full-screen player interface with lyrics and visualizations
- **ScrollingLyrics**: Animated lyrics display with auto-scroll and user interaction
- **PlayListItem/TrackItem**: Reusable music track components
### API Integration
- **Monster Siren API**: Fetches songs, albums, and metadata via `src/apis/index.ts`
- **Resource URL Validation**: Automatic refresh of cached URLs when servers rotate resources
- **Preloading System**: Smart audio preloading with cache management
### Browser Compatibility
- **Chrome**: Uses service worker, full CSP support
- **Firefox**: Uses background scripts, modified CSP, specific gecko settings
- **Safari**: Uses background page (background.html) instead of service worker
- **Prebuild Scripts**: Automatically modify manifest.json and HTML for each platform
### Storage Strategy
- **Favorites**: Stored in Chrome storage API (fallback to localStorage)
- **Preferences**: Browser-specific storage with graceful degradation
- **Audio Cache**: In-memory preloading with size limits
### Resource Management
- **Audio Preloading**: Validates and preloads next track during playback
- **URL Refresh Logic**: Checks resource availability before playback/preload
- **Cache Invalidation**: Automatic cleanup when resource URLs change
### Shuffle/Random Play Logic
- **shuffleList**: Array storing the shuffled order of original list indices
- **currentIndex**: In shuffle mode, this is the index within shuffleList
- **Accessing current song**: `list[shuffleList[currentIndex]]` in shuffle mode
- **getNextIndex**: Returns the actual list index of the next song to play
## File Structure Notes
### `/src/utils/`
- **songResourceChecker.ts**: Centralized resource validation and refresh logic
- **audioVisualizer.ts**: Real-time audio analysis for visual effects
- **artistsOrganize.ts**: Helper for formatting artist names
### `/scripts/`
- **prebuild-chrome.js**: Removes localhost dev configs for production
- **prebuild-firefox.js**: Adapts manifest for Firefox compatibility
- **prebuild-safari.js**: Creates background.html and adapts manifest for Safari
### `/public/`
- **manifest.json**: Extension manifest (modified by prebuild scripts)
- **content.js**: Injects the Vue app into target websites
- **background.js**: Extension background script
## Code Style and Formatting
### Indentation
- **This project uses Tab indentation (not spaces)**
- Ensure all code edits maintain consistent Tab indentation
- When editing files, preserve the existing Tab character formatting
## Development Considerations
### Extension Context
- Uses hash routing for browser extension compatibility
- CSP restrictions require specific script and style handling
- Cross-origin requests limited to declared host permissions
### Performance
- Audio preloading system prevents playback interruptions
- Resource validation happens only when needed (playback/preload)
- GSAP animations with proper cleanup to prevent memory leaks
### Error Handling
- Graceful fallbacks for storage API unavailability
- Resource URL rotation handling with automatic refresh
- Cross-browser compatibility with feature detection
## Safari Extension Considerations
### Background Script Handling
Safari Web Extensions have different requirements for background scripts:
1. **Background Page vs Service Worker**: Safari uses `background.page` instead of `service_worker`
2. **Background HTML**: The prebuild script creates `background.html` that loads `background.js`
3. **Manifest Configuration**: Uses `"background": { "page": "background.html", "persistent": false }`
### Auto-redirect Functionality
The auto-redirect feature in Safari may require special handling due to:
- Different WebKit extension APIs
- Safari's stricter security policies
- Tab management differences from Chromium
### Building for Safari
```bash
npm run build:safari # Creates background.html and Safari-specific manifest
```
The Safari build process:
1. Removes localhost development configurations
2. Converts `service_worker` to `background.page`
3. Creates `background.html` wrapper for `background.js`
4. Adds Safari-specific browser settings

View File

@ -8,7 +8,6 @@
"build": "echo 'No platform specified, will build for Chromium.' && npm run build-chrome",
"build:chrome": "npm run prebuild:chrome && vue-tsc -b && vite build && cp -r public/* dist/",
"build:firefox": "npm run prebuild:firefox && vue-tsc -b && vite build && cp -r public/* dist/",
"build:safari": "npm run prebuild:safari && vue-tsc -b && vite build && cp -r public/* dist/",
"dev:refresh": "vue-tsc -b && vite build && cp -r public/* dist/",
"build:watch": "vite build --watch",
"preview": "vite preview",
@ -16,8 +15,7 @@
"quality-check": "biome ci",
"qc": "npm run quality-check",
"prebuild:chrome": "node scripts/prebuild-chrome.js",
"prebuild:firefox": "node scripts/prebuild-firefox.js",
"prebuild:safari": "node scripts/prebuild-safari.js"
"prebuild:firefox": "node scripts/prebuild-firefox.js"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",

View File

@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "MSR Mod",
"version": "0.0.7",
"version": "0.0.5",
"description": "塞壬唱片Monster Siren Records官网的替代前端。",
"content_scripts": [
{

View File

@ -1,359 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
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'));
// 移除本地调试相关的配置
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();
}
// 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
};
}
// 创建 background.html 文件用于 Safari
const backgroundHtmlPath = path.join(__dirname, '../public/background.html');
const backgroundHtmlContent = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>MSR Mod Background</title>
</head>
<body>
<h1>MSR Mod Background Page</h1>
<p>If you can see this page, the background page is loaded!</p>
<div id="log"></div>
<script>
// 创建日志函数,同时显示在页面和控制台
function log(message) {
console.log(message);
const logDiv = document.getElementById('log');
if (logDiv) {
logDiv.innerHTML += '<div>' + message + '</div>';
}
}
log('=== SAFARI BACKGROUND PAGE LOADED ===');
log('Document ready state: ' + document.readyState);
log('Location: ' + location.href);
log('Time: ' + new Date().toISOString());
// 确保在 Safari 中正确加载脚本
try {
log('Safari extension context: ' + JSON.stringify({
chrome: typeof chrome,
browser: typeof browser,
safari: typeof safari
}));
} catch (e) {
log('Error in background.html: ' + e.message);
}
// 监听事件
document.addEventListener('DOMContentLoaded', function() {
log('=== DOMContentLoaded fired ===');
});
window.addEventListener('load', function() {
log('=== Window load fired ===');
});
log('About to load background.js...');
</script>
<script src="background.js" onload="log('background.js loaded successfully')" onerror="log('Failed to load background.js')"></script>
<script>
log('=== After background.js script tag ===');
</script>
</body>
</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 = `
console.log("=== Safari background.js starting ===");
console.log("Available APIs:", {
chrome: typeof chrome,
browser: typeof browser,
safari: typeof safari
});
// Safari 特殊处理
if (typeof chrome === 'undefined' && typeof browser === 'undefined') {
console.log("No extension APIs available in Safari");
// 如果没有扩展 API创建一个空的对象避免错误
window.chrome = {
webRequest: { onBeforeRequest: { addListener: () => {} } },
storage: { sync: { get: () => Promise.resolve({}) } },
tabs: { create: () => {}, remove: () => {}, update: () => {} },
runtime: {
getURL: (path) => path,
onMessage: { addListener: () => {} }
}
};
}
// Safari 消息监听器:处理来自 content script 的重定向请求
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Background received message:', message);
if (message.action === 'redirect_to_extension') {
console.log('Processing redirect request from content script');
try {
// 创建新标签页并打开扩展
const extensionUrl = chrome.runtime.getURL('index.html');
chrome.tabs.create({ url: extensionUrl }, (newTab) => {
console.log('New extension tab created:', newTab.id);
// 关闭原始标签页
if (sender.tab && sender.tab.id) {
chrome.tabs.remove(sender.tab.id);
}
sendResponse({ success: true, url: extensionUrl });
});
} catch (error) {
console.error('Failed to redirect:', error);
sendResponse({ success: false, error: error.message });
}
return true; // 保持消息通道开放
}
});
}
`;
// 替换 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);
// 兼容 Safari 的浏览器 API
const browserAPI = typeof browser !== 'undefined' ? browser : chrome;
// 异步函数:检查重定向偏好设置
async function checkRedirectPreference() {
try {
console.log('Checking redirect preferences...');
// 读取偏好设置
const pref = await browserAPI.storage.sync.get('preferences');
console.log('Retrieved preferences:', pref);
// 检查自动重定向设置(默认为 true
const shouldRedirect = pref === undefined ||
pref.preferences === undefined ||
pref.preferences.autoRedirect === undefined ||
pref.preferences.autoRedirect === true;
console.log('Should redirect:', shouldRedirect);
return shouldRedirect;
} catch (error) {
console.error('Error reading preferences:', error);
// 如果读取偏好设置失败,默认重定向
return true;
}
}
// 执行重定向的函数
function performRedirect() {
console.log('Performing redirect to extension...');
try {
// 对于 Safari我们需要使用消息传递来请求重定向
// 因为 content script 无法直接访问 chrome.runtime.getURL
// 方案1尝试通过消息传递
if (typeof chrome !== 'undefined' && chrome.runtime) {
chrome.runtime.sendMessage({action: 'redirect_to_extension'}, (response) => {
if (chrome.runtime.lastError) {
console.log('Message sending failed, trying direct redirect...');
// 方案2尝试直接重定向可能在某些情况下有效
window.location.href = 'safari-web-extension://[extension-id]/index.html';
}
});
} else {
console.log('Chrome runtime not available, trying alternative redirect...');
// 方案3显示提示让用户手动打开扩展
document.body.innerHTML = \`
<div style="
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1a1a;
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
z-index: 10000;
">
<h2>MSR Mod Extension Detected</h2>
<p>Please click the MSR Mod extension icon in your Safari toolbar to open the app.</p>
<button onclick="window.close()" style="
background: #007AFF;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
">Close Tab</button>
</div>
\`;
}
} catch (error) {
console.error('Redirect failed:', error);
}
}
// 主逻辑:检查页面并根据偏好设置决定是否重定向
async function main() {
// 检查是否是目标页面
if (window.location.pathname === '/' || window.location.href.includes('appShell.html')) {
console.log('Detected target page, checking preferences...');
// 检查偏好设置
const shouldRedirect = await checkRedirectPreference();
if (shouldRedirect) {
console.log('Auto-redirect is enabled, proceeding with redirect...');
performRedirect();
} else {
console.log('Auto-redirect is disabled, skipping redirect.');
}
}
}
// 执行主逻辑
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');
}
// 添加 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');
// 替换脚本地址
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 for Safari');
}
// 执行处理
try {
processManifest();
processIndexHtml();
console.log('🎉 Safari build preparation completed!');
} catch (error) {
console.error('❌ Error during Safari build preparation:', error);
process.exit(1);
}

View File

@ -9,8 +9,6 @@ import LeftArrowIcon from './assets/icons/leftarrow.vue'
import CorgIcon from './assets/icons/corg.vue'
import { watch } from 'vue'
import UpdatePopup from './components/UpdatePopup.vue'
const presentPreferencePanel = ref(false)
const route = useRoute()
@ -23,8 +21,6 @@ watch(() => presentPreferencePanel, (value) => {
</script>
<template>
<UpdatePopup />
<div class="w-screen h-screen overflow-hidden bg-[#191919]">
<div class="flex flex-col w-full h-full overflow-y-auto">
<div class="py-8 px-4 md:px-8 w-screen bg-gradient-to-b from-[#00000080] to-transparent z-20 absolute top-0">
@ -81,4 +77,4 @@ watch(() => presentPreferencePanel, (value) => {
</div>
<PreferencePanel :present="presentPreferencePanel" @dismiss="presentPreferencePanel = false" />
</div>
</template>
</template>

View File

@ -1,15 +0,0 @@
<script setup lang="ts">
defineProps<{
size: number
}>()
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" :class="`w-${size} h-${size}`">
<rect x="3" y="9" width="2" height="6" rx="1"></rect>
<rect x="7" y="5" width="2" height="14" rx="1"></rect>
<rect x="11" y="7" width="2" height="10" rx="1"></rect>
<rect x="15" y="4" width="2" height="16" rx="1"></rect>
<rect x="19" y="10" width="2" height="4" rx="1"></rect>
</svg>
</template>

View File

@ -2,14 +2,17 @@
import { artistsOrganize } from '../utils'
import { ref } from 'vue'
import { useFavourites } from '../stores/useFavourites'
import apis from '../apis'
import axios from 'axios'
import StarSlashIcon from '../assets/icons/starslash.vue'
import { onMounted } from 'vue'
const favourites = useFavourites()
const hover = ref(false)
defineProps<{
const props = defineProps<{
item: QueueItem
index: number
}>()
@ -17,6 +20,27 @@ defineProps<{
const emit = defineEmits<{
(e: 'play', index: number): void
}>()
onMounted(async () => {
try {
//
await axios.head(props.item.song.sourceUrl ?? '', {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
params: {
_t: Date.now() //
}
})
} catch (error) {
//
const updatedSong = await apis.getSong(props.item.song.cid)
console.log('Updated song:', updatedSong)
favourites.updateSongInFavourites(props.item.song.cid, updatedSong)
}
})
</script>
<template>

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { artistsOrganize, supportsWebAudioVisualization } from '../utils'
import { artistsOrganize } from '../utils'
import XIcon from '../assets/icons/x.vue'
import UpHyphenIcon from '../assets/icons/uphypen.vue'
import DownHyphenIcon from '../assets/icons/downhyphen.vue'
import SoundwaveIcon from '../assets/icons/soundwave.vue'
import { ref } from 'vue'
@ -19,9 +18,6 @@ const playQueueStore = usePlayQueueStore()
const hover = ref(false)
//
const isAudioVisualizationSupported = supportsWebAudioVisualization()
function moveUp() {
if (props.index === 0) return
@ -160,14 +156,12 @@ function removeItem() {
<img :src="queueItem.album?.coverUrl" />
<div class="w-full h-full absolute top-0 left-0 bg-neutral-900/75 flex justify-center items-center"
v-if="isCurrent">
<!-- 在支持的浏览器上显示可视化否则显示音波图标 -->
<div v-if="isAudioVisualizationSupported" style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]">
<div style="height: 1rem;" class="flex justify-center items-center gap-[.125rem]">
<div class="bg-white w-[.125rem] rounded-full" v-for="(bar, index) in playQueueStore.visualizer"
:key="index" :style="{
height: `${Math.max(10, bar)}%`
}" />
</div>
<SoundwaveIcon v-else :size="6" class="text-white animate-pulse" />
</div>
</div>
<div class="flex flex-col text-left flex-auto w-0">

View File

@ -1,17 +1,15 @@
<!-- Player.vue - 添加预加载功能 -->
<script setup lang="ts">
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useFavourites } from '../stores/useFavourites'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
import { usePlayQueueStore } from '../stores/usePlayQueueStore'
import { useTemplateRef, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router'
import PlayIcon from '../assets/icons/play.vue'
import PauseIcon from '../assets/icons/pause.vue'
import { audioVisualizer, checkAndRefreshSongResource, supportsWebAudioVisualization } from '../utils'
import LoadingIndicator from '../assets/icons/loadingindicator.vue'
import { audioVisualizer } from '../utils'
const playQueueStore = usePlayQueueStore()
const favourites = useFavourites()
const route = useRoute()
const player = useTemplateRef('playerRef')
@ -19,21 +17,16 @@ const player = useTemplateRef('playerRef')
console.log('[Player] 检查 store 方法:', {
preloadNext: typeof playQueueStore.preloadNext,
getPreloadedAudio: typeof playQueueStore.getPreloadedAudio,
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio,
clearPreloadedAudio: typeof playQueueStore.clearPreloadedAudio
})
//
const currentTrack = computed(() => {
if (
playQueueStore.playMode.shuffle &&
playQueueStore.shuffleList.length > 0
) {
return playQueueStore.list[
playQueueStore.shuffleList[playQueueStore.currentIndex]
]
if (playQueueStore.playMode.shuffle && playQueueStore.shuffleList.length > 0) {
return playQueueStore.list[playQueueStore.shuffleList[playQueueStore.currentIndex]]
} else {
return playQueueStore.list[playQueueStore.currentIndex]
}
return playQueueStore.list[playQueueStore.currentIndex]
})
//
@ -42,132 +35,86 @@ const currentAudioSrc = computed(() => {
return track ? track.song.sourceUrl : ''
})
watch(
() => playQueueStore.isPlaying,
(newValue) => {
if (newValue) {
player.value?.play()
setMetadata()
} else {
player.value?.pause()
}
},
)
watch(() => playQueueStore.isPlaying, (newValue) => {
if (newValue) {
player.value?.play()
setMetadata()
}
else { player.value?.pause() }
})
//
watch(
() => playQueueStore.currentIndex,
async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
watch(() => playQueueStore.currentIndex, async () => {
console.log('[Player] 当前索引变化:', playQueueStore.currentIndex)
// 使
const track = currentTrack.value
if (track) {
const songId = track.song.cid
// 使
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)
},
)
try {
const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
// 使
const preloadedAudio = playQueueStore.getPreloadedAudio(songId)
if (preloadedAudio) {
console.log(`[Player] 使用预加载的音频: ${track.song.name}`)
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
// 使
if (player.value) {
//
player.value.src = preloadedAudio.src
player.value.currentTime = 0
// 使
playQueueStore.clearPreloadedAudio(songId)
// 使
playQueueStore.clearPreloadedAudio(songId)
//
if (playQueueStore.isPlaying) {
await nextTick()
player.value.play().catch(console.error)
}
playQueueStore.isBuffering = false
//
if (playQueueStore.isPlaying) {
await nextTick()
player.value.play().catch(console.error)
}
} else {
console.log(`[Player] 正常加载音频: ${track.song.name}`)
playQueueStore.isBuffering = true
//
if (updatedSong.sourceUrl !== track.song.sourceUrl) {
playQueueStore.clearPreloadedAudio(songId)
}
playQueueStore.isBuffering = false
}
} catch (error) {
console.error('[Player] 处理预加载音频时出错:', error)
} else {
console.log(`[Player] 正常加载音频: ${track.song.name}`)
playQueueStore.isBuffering = true
}
} catch (error) {
console.error('[Player] 处理预加载音频时出错:', error)
playQueueStore.isBuffering = true
}
}
setMetadata()
setMetadata()
//
setTimeout(async () => {
try {
console.log('[Player] 尝试预加载下一首歌')
//
setTimeout(() => {
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)
//
if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext()
playQueueStore.limitPreloadCache()
} else {
console.error('[Player] preloadNext 不是一个函数')
}
}, 1000)
},
)
} catch (error) {
console.error('[Player] 预加载失败:', error)
}
}, 1000)
})
function artistsOrganize(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(' / ')
}
function setMetadata() {
if ('mediaSession' in navigator) {
const current = currentTrack.value
let current = currentTrack.value
if (!current) return
navigator.mediaSession.metadata = new MediaMetadata({
@ -175,12 +122,8 @@ function setMetadata() {
artist: artistsOrganize(current.song.artists ?? []),
album: current.album?.name,
artwork: [
{
src: current.album?.coverUrl ?? '',
sizes: '500x500',
type: 'image/png',
},
],
{ src: current.album?.coverUrl ?? '', sizes: '500x500', type: 'image/png' },
]
})
navigator.mediaSession.setActionHandler('previoustrack', playPrevious)
@ -190,21 +133,16 @@ function setMetadata() {
playQueueStore.currentTime = player.value?.currentTime ?? 0
}
watch(
() => playQueueStore.updatedCurrentTime,
(newValue) => {
if (newValue === null) {
return
}
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
},
)
watch(() => playQueueStore.updatedCurrentTime, (newValue) => {
if (newValue === null) { return }
if (player.value) player.value.currentTime = newValue
playQueueStore.updatedCurrentTime = null
})
}
function playNext() {
if (playQueueStore.currentIndex === playQueueStore.list.length - 1) {
console.log('at the bottom, pause')
console.log("at the bottom, pause")
playQueueStore.currentIndex = 0
if (playQueueStore.playMode.repeat === 'all') {
playQueueStore.currentIndex = 0
@ -220,17 +158,11 @@ function playNext() {
}
function playPrevious() {
if (
player.value &&
(player.value.currentTime ?? 0) < 5 &&
playQueueStore.currentIndex > 0
) {
if (player.value && (player.value.currentTime ?? 0) < 5 && playQueueStore.currentIndex > 0) {
playQueueStore.currentIndex--
playQueueStore.isPlaying = true
} else {
if (player.value) {
player.value.currentTime = 0
}
if (player.value) { player.value.currentTime = 0 }
}
}
@ -247,10 +179,8 @@ function updateCurrentTime() {
const preloadTrigger = (config.preloadTrigger || 50) / 100 //
const remainingTimeThreshold = config.remainingTimeThreshold || 30
if (
(progress > preloadTrigger || remainingTime < remainingTimeThreshold) &&
!playQueueStore.isPreloading
) {
if ((progress > preloadTrigger || remainingTime < remainingTimeThreshold) && !playQueueStore.isPreloading) {
try {
if (typeof playQueueStore.preloadNext === 'function') {
playQueueStore.preloadNext()
@ -264,160 +194,115 @@ function updateCurrentTime() {
}
}
//
const isAudioVisualizationSupported = supportsWebAudioVisualization()
console.log('[Player] 音频可视化支持状态:', isAudioVisualizationSupported)
console.log('[Player] 初始化 audioVisualizer')
const { barHeights, connectAudio, isAnalyzing, error } = audioVisualizer({
sensitivity: 1.5,
barCount: 6,
maxDecibels: -10,
bassBoost: 0.8,
midBoost: 1.2,
trebleBoost: 1.4,
threshold: 0
})
//
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 或不支持的浏览器)')
}
console.log('[Player] audioVisualizer 返回值:', { barHeights: barHeights.value, isAnalyzing: isAnalyzing.value })
//
watch(
() => playQueueStore.list.length,
async (newLength) => {
console.log('[Player] 播放列表长度变化:', newLength)
if (newLength === 0) {
console.log('[Player] 播放列表为空,跳过连接')
return
}
watch(() => playQueueStore.list.length, async (newLength) => {
console.log('[Player] 播放列表长度变化:', newLength)
if (newLength === 0) {
console.log('[Player] 播放列表为空,跳过连接')
return
}
// audio
await nextTick()
// 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] ❌ 音频元素不存在')
}
if (player.value) {
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] ❌ 音频元素不存在')
}
playQueueStore.visualizer = barHeights.value
playQueueStore.visualizer = barHeights.value
//
setTimeout(() => {
playQueueStore.preloadNext()
}, 2000)
//
setTimeout(() => {
playQueueStore.preloadNext()
}, 2000)
//
if (player.value) {
initializeVolume()
}
},
)
//
if (player.value) {
initializeVolume()
}
})
//
watch(
() => player.value,
(audioElement) => {
if (audioElement && playQueueStore.list.length > 0 && isAudioVisualizationSupported) {
connectAudio(audioElement)
}
},
)
watch(() => player.value, (audioElement) => {
if (audioElement && playQueueStore.list.length > 0) {
connectAudio(audioElement)
}
})
//
watch(
() => barHeights.value,
(newHeights) => {
playQueueStore.visualizer = newHeights
},
{ deep: true },
)
watch(() => barHeights.value, (newHeights) => {
playQueueStore.visualizer = newHeights
}, { deep: true })
//
watch(
() => error.value,
(newError) => {
if (newError) {
console.error('[Player] 可视化器错误:', newError)
}
},
)
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
watch(() => playQueueStore.playMode.shuffle, (isShuffle) => {
if (isShuffle) {
const currentIndex = playQueueStore.currentIndex
const trackCount = playQueueStore.list.length
// 1.
let shuffledList = [...Array(currentIndex).keys()]
// 1.
let shuffledList = [...Array(currentIndex).keys()]
// 2.
const shuffleSpace = [...Array(trackCount).keys()].filter((index) =>
playQueueStore.shuffleCurrent
? index >= currentIndex
: index > currentIndex,
)
// 2.
let shuffleSpace = [...Array(trackCount).keys()].filter(index =>
playQueueStore.shuffleCurrent ? index >= currentIndex : index > currentIndex
)
// 3.
shuffleSpace.sort(() => Math.random() - 0.5)
// 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]
// 4. currentIndex
if (!playQueueStore.shuffleCurrent) {
shuffledList.push(currentIndex)
}
//
setTimeout(() => {
playQueueStore.clearAllPreloadedAudio()
playQueueStore.preloadNext()
}, 500)
},
)
// 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
@ -428,7 +313,7 @@ function initializeVolume() {
if (player.value) {
const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) {
const volumeValue = Number.parseFloat(savedVolume)
const volumeValue = parseFloat(savedVolume)
player.value.volume = volumeValue
console.log('[Player] 初始化音量:', volumeValue)
} else {
@ -454,7 +339,7 @@ function syncVolumeFromStorage() {
if (player.value) {
const savedVolume = localStorage.getItem('audioVolume')
if (savedVolume) {
const volumeValue = Number.parseFloat(savedVolume)
const volumeValue = parseFloat(savedVolume)
if (player.value.volume !== volumeValue) {
player.value.volume = volumeValue
}
@ -510,7 +395,7 @@ setInterval(syncVolumeFromStorage, 100)
<RouterLink to="/playroom">
<div class="flex items-center w-32 h-9">
<span class="truncate text-xs">{{ getCurrentTrack()?.song.name }}</span>
<span class="truncate">{{ getCurrentTrack()?.song.name }}</span>
</div>
</RouterLink>
@ -519,14 +404,12 @@ setInterval(syncVolumeFromStorage, 100)
}">
<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 v-else 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>

View File

@ -31,7 +31,7 @@ const version = computed(() => {
class="bg-black/30 w-screen h-screen absolute top-0 left-0 z-30 flex justify-center items-center select-none"
@click="$emit('dismiss')">
<div
class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-y-auto modal-content"
class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-scroll modal-content"
@click.stop>
<div
class="flex justify-between items-center p-8 sticky top-0 bg-gradient-to-b from-neutral-900 to-neutral-900/0 z-10">
@ -126,27 +126,7 @@ const version = computed(() => {
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 GitHub 仓库</div>
<div class="text-sm text-white/80"> Issue 中提交问题或建议或者 Bug 的事情就拜托了大佬桑鞠躬</div>
</div>
</a>
</li>
<li class="odd:bg-neutral-300/5">
<a href="https://discord.gg/QQUfeb2gzH" target="_blank"
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 Discord 社群</div>
<div class="text-sm text-white/80">在社群中提交问题或建议或是来聊聊明日方舟的音乐吧</div>
</div>
</a>
</li>
<li class="odd:bg-neutral-300/5">
<a href="https://trello.com/b/Ju1TRXla" target="_blank"
class="flex justify-between items-center px-6 py-4 w-full text-left hover:bg-neutral-300/10 transition-all">
<div class="flex flex-col">
<div class="text-base text-white">前往 Trello 看板</div>
<div class="text-sm text-white/80">了解 MSR Mod 目前的开发进度</div>
<div class="text-sm text-white/80"> Bug 的事情就拜托了大佬桑鞠躬</div>
</div>
</a>
</li>

View File

@ -543,39 +543,8 @@ watch(() => props.lrcSrc, async (newSrc) => {
}
}, { immediate: true })
//
let handleVisibilityChange: (() => void) | null = null
//
function setupPageFocusHandlers() {
handleVisibilityChange = () => {
if (document.hidden) {
//
if (scrollTween) scrollTween.pause()
if (highlightTween) highlightTween.pause()
} else {
//
if (scrollTween && scrollTween.paused()) scrollTween.resume()
if (highlightTween && highlightTween.paused()) highlightTween.resume()
//
nextTick(() => {
if (currentLineIndex.value >= 0 && autoScroll.value && !userScrolling.value) {
scrollToLine(currentLineIndex.value, false) // 使
}
})
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
}
//
onMounted(() => {
//
setupPageFocusHandlers()
//
if (controlPanel.value) {
gsap.fromTo(controlPanel.value,
@ -608,11 +577,6 @@ onUnmounted(() => {
if (scrollTween) scrollTween.kill()
if (highlightTween) highlightTween.kill()
if (userScrollTimeout) clearTimeout(userScrollTimeout)
//
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
})
//

View File

@ -1,57 +0,0 @@
<script lang="ts" setup>
import XIcon from '../assets/icons/x.vue'
import { ref, onMounted } from 'vue'
import { useUpdatePopup } from '../stores/useUpdatePopup'
const updatePopupStore = useUpdatePopup()
const showPopup = ref(false)
const version = updatePopupStore.getCurrentVersion()
//
const handleDismiss = async () => {
showPopup.value = false
//
await updatePopupStore.markUpdatePopupShown()
}
//
onMounted(async () => {
// store
if (!updatePopupStore.isLoaded) {
await updatePopupStore.initializeUpdatePopup()
}
//
const shouldShow = await updatePopupStore.shouldShowUpdatePopup()
showPopup.value = shouldShow
})
</script>
<template>
<div v-if="showPopup" class="absolute top-0 left-0 w-screen h-screen bg-neutral-700/30 flex justify-center items-center select-none z-50">
<div class="bg-neutral-900/80 shadow-[0_0_16px_0_rgba(0,0,0,0.5)] backdrop-blur-2xl border border-[#ffffff39] rounded-lg w-[60rem] h-3/4 relative overflow-y-auto text-white">
<div
class="flex justify-between items-center p-8 sticky top-0 bg-gradient-to-b from-neutral-900 to-neutral-900/0 z-10">
<div class="text-white text-2xl font-semibold">MSR Mod 已更新至 {{version}}</div>
<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 transition-all duration-200 hover:bg-neutral-700/80"
@click="handleDismiss">
<XIcon :size="4" />
</button>
</div>
<div class="flex flex-col gap-4 mb-8 px-8 text-lg">
<p>最近两周有家事同时我的 MacBook Pro MagSafe 出了故障还在 Genius Bar 维修所以开发进程受到了一些影响</p>
<p>MSR Mod 现在有两种渠道接收错误及意见反馈如果你对 MSR Mod 有任何的意见建议或是想要回报错误及体验困惑之处欢迎前往 <a href="https://github.com/Astrian/msr-mod/issues" target="_blank" class="underline">GitHub Issue</a> <a href="https://discord.gg/QQUfeb2gzH" target="_blank" class="underline">Discord 社群</a> 向我们反馈如果你的意见或错误回报被接受我们会将其放入 <a href="https://trello.com/b/Ju1TRXla" target="_blank" class="underline">Trello 看板</a> 中进行跟踪敬请留意</p>
<ul class="list-disc list-inside">
<li>新增版本更新提示对话框将在 MSR Mod 更新后首次启动显示</li>
<li>增强对 Apple Safari 浏览器的兼容性支持</li>
<li>修复 Windows 和其他操作系统中偏好设置面板滚动条显示异常的问题</li>
<li>在偏好设置面板中新增 Discord 社群和 Trello 看板链接</li>
</ul>
</div>
</div>
</div>
</template>

View File

@ -83,9 +83,6 @@ onMounted(async () => {
thumbUpdate()
setupEntranceAnimations()
//
setupPageFocusHandlers()
})
function timeFormatter(time: number) {
@ -93,7 +90,7 @@ function timeFormatter(time: number) {
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 (isNaN(minutes) || isNaN(seconds)) { return '-:--' }
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
@ -158,7 +155,7 @@ function createVolumeDraggable() {
// localStorage
localStorage.setItem('audioVolume', newVolume.toString())
},
onDragEnd: () => {
onDragEnd: function () {
//
localStorage.setItem('audioVolume', volume.value.toString())
}
@ -422,98 +419,9 @@ watch(() => [preferences.presentLyrics, getCurrentTrack()?.song.lyricUrl], (newV
}
}, { immediate: true })
//
let handleVisibilityChange: (() => void) | null = null
let handlePageFocus: (() => void) | null = null
onUnmounted(() => {
//
if (handleVisibilityChange) {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
if (handlePageFocus) {
window.removeEventListener('focus', handlePageFocus)
}
})
//
function setupPageFocusHandlers() {
handleVisibilityChange = () => {
if (document.hidden) {
//
console.log('[Playroom] 页面失去焦点,暂停动画')
} else {
//
console.log('[Playroom] 页面重新获得焦点,同步状态')
nextTick(() => {
resyncLyricsState()
})
}
}
handlePageFocus = () => {
console.log('[Playroom] 窗口获得焦点,同步状态')
nextTick(() => {
resyncLyricsState()
})
}
//
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('focus', handlePageFocus)
}
//
function resyncLyricsState() {
const currentTrack = getCurrentTrack()
if (!currentTrack) { return }
console.log('[Playroom] 重新同步歌词状态')
//
if (controllerRef.value) {
gsap.set(controllerRef.value, {
marginLeft: '0rem',
marginRight: '0rem'
})
}
if (lyricsSection.value) {
gsap.set(lyricsSection.value, {
opacity: 1,
x: 0,
y: 0,
scale: 1
})
}
//
const shouldShowLyrics = preferences.presentLyrics && currentTrack.song.lyricUrl ? true : false
if (shouldShowLyrics !== presentLyrics.value) {
console.log(`[Playroom] 歌词状态不一致,重新设置: ${presentLyrics.value} -> ${shouldShowLyrics}`)
//
presentLyrics.value = shouldShowLyrics
//
if (shouldShowLyrics) {
nextTick(() => {
const tl = gsap.timeline()
tl.from(controllerRef.value, {
marginRight: '-40rem',
duration: 0.4,
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"
)
})
}
}
}
// New: Watch for track changes and animate
watch(() => playQueueStore.currentIndex, () => {
if (albumCover.value) {
@ -605,9 +513,9 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="w-full flex justify-between">
<!-- ...existing time display code... -->
<div class="font-medium flex-1 text-left text-xs relative">
<div class="font-medium flex-1 text-left 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">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
<span
class="text-white/90 absolute top-0">{{ timeFormatter(Math.floor(playQueueStore.currentTime)) }}</span>
</div>
@ -618,7 +526,7 @@ watch(() => playQueueStore.currentIndex, () => {
<div class="flex flex-1">
<div class="flex-1" />
<button
class="text-white/90 text-xs font-medium text-right relative transition-colors duration-200 hover:text-white"
class="text-white/90 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>

View File

@ -1,6 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { checkAndRefreshSongResource } from '../utils'
import { defineStore } from "pinia"
import { ref, computed } from "vue"
export const usePlayQueueStore = defineStore('queue', () => {
const list = ref<QueueItem[]>([])
@ -14,11 +13,11 @@ export const usePlayQueueStore = defineStore('queue', () => {
const visualizer = ref<number[]>([0, 0, 0, 0, 0, 0])
const shuffleList = ref<number[]>([])
const playMode = ref<{
shuffle: boolean
shuffle: boolean,
repeat: 'off' | 'single' | 'all'
}>({
shuffle: false,
repeat: 'off',
repeat: 'off'
})
const shuffleCurrent = ref<boolean | undefined>(undefined)
@ -36,13 +35,10 @@ export const usePlayQueueStore = defineStore('queue', () => {
}
if (playMode.value.shuffle && shuffleList.value.length > 0) {
// 当前在 shuffleList 中的位置
const currentShuffleIndex = currentIndex.value
const currentShuffleIndex = shuffleList.value.indexOf(currentIndex.value)
if (currentShuffleIndex < shuffleList.value.length - 1) {
// 返回下一个位置对应的原始 list 索引
return shuffleList.value[currentShuffleIndex + 1]
} else if (playMode.value.repeat === 'all') {
// 返回第一个位置对应的原始 list 索引
return shuffleList.value[0]
}
return -1
@ -59,14 +55,19 @@ export const usePlayQueueStore = defineStore('queue', () => {
// 预加载下一首歌
const preloadNext = async () => {
const nextIndex = getNextIndex.value
if (nextIndex === -1) {
return
}
// 获取下一首歌曲对象
// nextIndex 已经是原始 list 中的索引
const nextSong = list.value[nextIndex]
let nextSong
if (playMode.value.shuffle && shuffleList.value.length > 0) {
nextSong = list.value[shuffleList.value[nextIndex]]
} else {
nextSong = list.value[nextIndex]
}
if (!nextSong || !nextSong.song) {
return
@ -88,24 +89,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
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'
@ -124,20 +107,19 @@ export const usePlayQueueStore = defineStore('queue', () => {
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)
console.error(`[Store] 预加载音频失败: ${e}`)
isPreloading.value = false
preloadProgress.value = 0
})
// 使用更新后的音频源
audio.src = updatedSong.sourceUrl!
// 设置音频源并开始加载
audio.src = nextSong.song.sourceUrl
} catch (error) {
console.error('[Store] 预加载过程出错:', error)
isPreloading.value = false
}
}
@ -185,7 +167,7 @@ export const usePlayQueueStore = defineStore('queue', () => {
progress: preloadProgress.value,
cacheSize: preloadedAudio.value.size,
cachedSongs: Array.from(preloadedAudio.value.keys()),
nextIndex: getNextIndex.value,
nextIndex: getNextIndex.value
})
}
@ -212,6 +194,6 @@ export const usePlayQueueStore = defineStore('queue', () => {
clearPreloadedAudio,
clearAllPreloadedAudio,
limitPreloadCache,
debugPreloadState,
debugPreloadState
}
})
})

View File

@ -1,204 +0,0 @@
import { defineStore } from "pinia"
import { ref } from "vue"
// 声明全局类型
declare global {
interface Window {
browser?: any
}
}
export const useUpdatePopup = defineStore('updatePopup', () => {
const isLoaded = ref(false)
const storageType = ref<'chrome' | 'localStorage' | 'memory'>('chrome')
// 获取当前版本号
const getCurrentVersion = (): string => {
try {
// 尝试从 Chrome 扩展 API 获取版本号
return chrome?.runtime?.getManifest?.()?.version || 'unknown'
} catch (error) {
return 'unknown'
}
}
// 检测可用的 API
const detectAvailableAPIs = () => {
// 检查原生 chrome API
try {
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
storageType.value = 'chrome'
return 'chrome'
}
} catch (error) {
// Silent fail
}
// 检查 window.chrome
try {
if (window.chrome && window.chrome.storage && window.chrome.storage.sync) {
storageType.value = 'chrome'
return 'chrome'
}
} catch (error) {
// Silent fail
}
// 检查 localStorage
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('msr_test', 'test')
localStorage.removeItem('msr_test')
storageType.value = 'localStorage'
return 'localStorage'
}
} catch (error) {
// Silent fail
}
// 都不可用,使用内存存储
storageType.value = 'memory'
return 'memory'
}
// 通用的获取存储值函数
const getStoredValue = async (key: string, defaultValue: any) => {
const type = detectAvailableAPIs()
try {
switch (type) {
case 'chrome':
return await new Promise((resolve) => {
const api = chrome?.storage?.sync || window.chrome?.storage?.sync
if (api) {
api.get({ [key]: defaultValue }, (result) => {
if (chrome.runtime.lastError) {
resolve(defaultValue)
} else {
resolve(result[key])
}
})
} else {
resolve(defaultValue)
}
})
case 'localStorage':
const stored = localStorage.getItem(`msr_${key}`)
const value = stored ? JSON.parse(stored) : defaultValue
return value
case 'memory':
default:
return defaultValue
}
} catch (error) {
return defaultValue
}
}
// 通用的设置存储值函数
const setStoredValue = async (key: string, value: any) => {
const type = storageType.value
try {
switch (type) {
case 'chrome':
return await new Promise<void>((resolve, reject) => {
const api = chrome?.storage?.sync || window.chrome?.storage?.sync
if (api) {
api.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve()
}
})
} else {
reject(new Error('Chrome storage API 不可用'))
}
})
case 'localStorage':
localStorage.setItem(`msr_${key}`, JSON.stringify(value))
break
case 'memory':
// 内存存储(不持久化)
break
}
} catch (error) {
throw error
}
}
// 检查是否需要显示更新弹窗
const shouldShowUpdatePopup = async (): Promise<boolean> => {
try {
const currentVersion = getCurrentVersion()
// 如果无法获取当前版本,不显示弹窗
if (currentVersion === 'unknown') {
return false
}
// 获取上次显示弹窗的版本号
const lastShownVersion = await getStoredValue('lastUpdatePopupVersion', '')
// 如果版本号不同,需要显示弹窗并更新存储的版本号
if (lastShownVersion !== currentVersion) {
await setStoredValue('lastUpdatePopupVersion', currentVersion)
return true
}
return false
} catch (error) {
console.error('检查更新弹窗状态失败:', error)
return false
}
}
// 标记已显示过更新弹窗(手动关闭时调用)
const markUpdatePopupShown = async () => {
try {
const currentVersion = getCurrentVersion()
if (currentVersion !== 'unknown') {
await setStoredValue('lastUpdatePopupVersion', currentVersion)
}
} catch (error) {
console.error('标记更新弹窗已显示失败:', error)
}
}
// 获取当前存储的版本号
const getLastShownVersion = async (): Promise<string> => {
return await getStoredValue('lastUpdatePopupVersion', '')
}
// 异步初始化函数
const initializeUpdatePopup = async () => {
try {
// 初始化存储类型检测
detectAvailableAPIs()
isLoaded.value = true
} catch (error) {
console.error('初始化更新弹窗 store 失败:', error)
isLoaded.value = true
}
}
// 立即初始化
initializeUpdatePopup()
return {
isLoaded,
storageType,
getCurrentVersion,
shouldShowUpdatePopup,
markUpdatePopupShown,
getLastShownVersion,
initializeUpdatePopup,
getStoredValue,
setStoredValue
}
})

View File

@ -1,98 +0,0 @@
/**
*
*/
/**
* Safari
* @returns {boolean} Safari true false
*/
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') &&
!ua.includes('chromium') &&
!ua.includes('android')
// 额外检查:使用 Safari 特有的 API
const isSafariByFeature = 'safari' in window ||
/^((?!chrome|android).)*safari/i.test(navigator.userAgent)
return isSafariBrowser || isSafariByFeature
}
/**
* Safari
* @returns {boolean} Safari true false
*/
export function isMobileSafari(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
}
/**
* Web Audio API
* @returns {boolean} true false
*/
export function supportsWebAudioVisualization(): boolean {
// Safari 在某些情况下对 AudioContext 的支持有限制
// 特别是在处理跨域音频资源时
if (isSafari()) {
console.log('[BrowserDetection] Safari detected, audio visualization disabled')
return false
}
// 检查基本的 Web Audio API 支持
const hasAudioContext = 'AudioContext' in window || 'webkitAudioContext' in window
const hasAnalyserNode = hasAudioContext && (
'AnalyserNode' in window ||
((window as any).AudioContext && 'createAnalyser' in (window as any).AudioContext.prototype)
)
return hasAudioContext && hasAnalyserNode
}
/**
*
* @returns {object}
*/
export function getBrowserInfo() {
const ua = navigator.userAgent
let browserName = 'Unknown'
let browserVersion = 'Unknown'
if (isSafari()) {
browserName = 'Safari'
const versionMatch = ua.match(/Version\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Chrome')) {
browserName = 'Chrome'
const versionMatch = ua.match(/Chrome\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Firefox')) {
browserName = 'Firefox'
const versionMatch = ua.match(/Firefox\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
} else if (ua.includes('Edge')) {
browserName = 'Edge'
const versionMatch = ua.match(/Edge\/(\d+\.\d+)/)
if (versionMatch) {
browserVersion = versionMatch[1]
}
}
return {
name: browserName,
version: browserVersion,
isSafari: isSafari(),
isMobileSafari: isMobileSafari(),
supportsAudioVisualization: supportsWebAudioVisualization()
}
}

View File

@ -1,17 +1,5 @@
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,
checkAndRefreshMultipleSongs,
isSafari,
isMobileSafari,
supportsWebAudioVisualization,
getBrowserInfo
}
export { artistsOrganize, audioVisualizer, cicdInfo }

View File

@ -1,80 +0,0 @@
import axios from 'axios'
import apis from '../apis'
/**
* URL
* @param song
* @param updateCallback
* @returns
*/
export const checkAndRefreshSongResource = async (
song: Song,
updateCallback?: (updatedSong: Song) => void
): Promise<Song> => {
if (!song.sourceUrl) {
console.warn('[ResourceChecker] 歌曲没有 sourceUrl:', song.name)
return song
}
try {
// 检查资源是否可用
await axios.head(song.sourceUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},
params: {
_t: Date.now() // 添加时间戳参数避免缓存
},
timeout: 5000 // 5秒超时
})
// 资源可用,返回原始歌曲
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
}
}
}
/**
*
* @param songs
* @param updateCallback
* @returns
*/
export const checkAndRefreshMultipleSongs = async (
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
}