forked from miao-moe/QZMusic_PC
fix: 优化&功能
- 播放列表记忆 - 播放列表添加模式设置项 - ProxyServer优化
This commit is contained in:
@@ -91,16 +91,7 @@ export class PluginSystem {
|
|||||||
error: 'Search not implemented'
|
error: 'Search not implemented'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
return await this.plugin.musicSearch.search(query, page, limit)
|
||||||
return await this.plugin.musicSearch.search(query, page, limit)
|
|
||||||
} catch (e: any) {
|
|
||||||
return {
|
|
||||||
list: [],
|
|
||||||
total: 0,
|
|
||||||
allPage: 0,
|
|
||||||
error: e.message || 'Plugin search error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLyric(id: string): Promise<any> {
|
async getLyric(id: string): Promise<any> {
|
||||||
|
|||||||
@@ -472,6 +472,26 @@ function notifyWaiters(task: DownloadTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all background downloads EXCEPT the one matching the given path
|
||||||
|
*/
|
||||||
|
function cancelAllDownloadsExcept(exceptPath: string) {
|
||||||
|
for (const [path, task] of downloadTasks) {
|
||||||
|
if (path !== exceptPath) {
|
||||||
|
console.log(`[Proxy] Cancelling competing download: ${path}`);
|
||||||
|
if (task.abortController) {
|
||||||
|
task.abortController.abort();
|
||||||
|
}
|
||||||
|
downloadTasks.delete(path);
|
||||||
|
// Cleanup temp file? Handled by error handler typically, but force check
|
||||||
|
try {
|
||||||
|
const tempPath = getTempPath(path);
|
||||||
|
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主代理函数 - 智能处理缓存和 Range 请求
|
* 主代理函数 - 智能处理缓存和 Range 请求
|
||||||
* 修复版本:正确处理流的复制,确保下载和响应同时进行
|
* 修复版本:正确处理流的复制,确保下载和响应同时进行
|
||||||
@@ -486,6 +506,9 @@ async function proxyAndCache(
|
|||||||
const requestedRange = req.headers.range;
|
const requestedRange = req.headers.range;
|
||||||
const isSeekRequest = requestedRange && !requestedRange.startsWith('bytes=0-');
|
const isSeekRequest = requestedRange && !requestedRange.startsWith('bytes=0-');
|
||||||
|
|
||||||
|
// NEW: Cancel other downloads to enforce "Current Song Priority"
|
||||||
|
cancelAllDownloadsExcept(cacheFilePath);
|
||||||
|
|
||||||
// 检查是否有正在进行的后台下载
|
// 检查是否有正在进行的后台下载
|
||||||
let task = downloadTasks.get(cacheFilePath);
|
let task = downloadTasks.get(cacheFilePath);
|
||||||
|
|
||||||
@@ -565,14 +588,25 @@ async function streamAndCache(
|
|||||||
cacheFilePath: string,
|
cacheFilePath: string,
|
||||||
cacheKey: string
|
cacheKey: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const controller = new AbortController();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
let response;
|
||||||
method: 'GET',
|
try {
|
||||||
headers: headers,
|
response = await fetch(targetUrl, {
|
||||||
});
|
method: 'GET',
|
||||||
|
headers: headers,
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
console.log(`[Proxy] Request aborted for ${targetUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw { statusCode: response.status, statusText: response.statusText };
|
throw { statusCode: response.status, statusText: response.statusText };
|
||||||
@@ -592,6 +626,13 @@ async function streamAndCache(
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const nodeStream = Readable.fromWeb(response.body);
|
const nodeStream = Readable.fromWeb(response.body);
|
||||||
nodeStream.pipe(res);
|
nodeStream.pipe(res);
|
||||||
|
|
||||||
|
// Handle cleanup if aborted during pipe (though pipe handles some)
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableEnded) {
|
||||||
|
nodeStream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
@@ -613,7 +654,7 @@ async function streamAndCache(
|
|||||||
currentSize: 0,
|
currentSize: 0,
|
||||||
totalSize: contentLength,
|
totalSize: contentLength,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
abortController: null,
|
abortController: controller,
|
||||||
promise: null,
|
promise: null,
|
||||||
waiters: [],
|
waiters: [],
|
||||||
};
|
};
|
||||||
@@ -645,6 +686,8 @@ async function streamAndCache(
|
|||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
clientConnected = false;
|
clientConnected = false;
|
||||||
console.log('[Proxy] Client disconnected');
|
console.log('[Proxy] Client disconnected');
|
||||||
|
// Do NOT abort download here anymore.
|
||||||
|
// We let it finish UNLESS a new song starts (handled by cancelAllDownloadsExcept).
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
@@ -652,7 +695,9 @@ async function streamAndCache(
|
|||||||
task.currentSize += chunk.length;
|
task.currentSize += chunk.length;
|
||||||
|
|
||||||
// 写入文件(始终进行)
|
// 写入文件(始终进行)
|
||||||
fileStream.write(chunk);
|
if (!fileStream.destroyed) {
|
||||||
|
fileStream.write(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
// 写入响应(如果客户端还连接着)
|
// 写入响应(如果客户端还连接着)
|
||||||
if (clientConnected && !res.writableEnded) {
|
if (clientConnected && !res.writableEnded) {
|
||||||
@@ -676,7 +721,11 @@ async function streamAndCache(
|
|||||||
});
|
});
|
||||||
|
|
||||||
nodeStream.on('error', (err: Error) => {
|
nodeStream.on('error', (err: Error) => {
|
||||||
console.error('[Proxy] Stream error:', err);
|
if (err.name === 'AbortError') {
|
||||||
|
console.log('[Proxy] Stream aborted');
|
||||||
|
} else {
|
||||||
|
console.error('[Proxy] Stream error:', err);
|
||||||
|
}
|
||||||
fileStream.destroy();
|
fileStream.destroy();
|
||||||
|
|
||||||
if (clientConnected && !res.headersSent) {
|
if (clientConnected && !res.headersSent) {
|
||||||
@@ -934,6 +983,11 @@ export function startProxyServer(persistCache: boolean = true) {
|
|||||||
console.warn(`[Proxy] Proxy error (Attempt ${currentAttempt}/${maxAttempts}):`, e);
|
console.warn(`[Proxy] Proxy error (Attempt ${currentAttempt}/${maxAttempts}):`, e);
|
||||||
|
|
||||||
if (currentAttempt < maxAttempts) {
|
if (currentAttempt < maxAttempts) {
|
||||||
|
if (res.headersSent) {
|
||||||
|
console.warn('[Proxy] Cannot retry because headers already sent');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Proxy] Error occurred, refreshing URL from plugin...');
|
console.log('[Proxy] Error occurred, refreshing URL from plugin...');
|
||||||
urlCache.delete(cacheKey);
|
urlCache.delete(cacheKey);
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,27 @@
|
|||||||
<!-- 播放设置 -->
|
<!-- 播放设置 -->
|
||||||
<div v-else-if="activeCategory === 'playback'" class="section">
|
<div v-else-if="activeCategory === 'playback'" class="section">
|
||||||
<h2 class="section-title">播放设置</h2>
|
<h2 class="section-title">播放设置</h2>
|
||||||
|
|
||||||
|
<!-- 列表添加模式 -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info">
|
||||||
|
<div class="setting-label">列表添加模式</div>
|
||||||
|
<div class="setting-desc">选择点击歌曲时的播放行为</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<div class="radio-group">
|
||||||
|
<label class="radio-option">
|
||||||
|
<input type="radio" value="replace" v-model="playerStore.addListMode">
|
||||||
|
<span class="radio-label">替换当前列表</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-option">
|
||||||
|
<input type="radio" value="append" v-model="playerStore.addListMode">
|
||||||
|
<span class="radio-label">添加到列表末尾</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
||||||
<p>音质、淡入淡出等设置即将推出</p>
|
<p>音质、淡入淡出等设置即将推出</p>
|
||||||
@@ -168,6 +189,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
|
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { usePlayerStore } from '../stores/player';
|
||||||
|
|
||||||
|
const playerStore = usePlayerStore();
|
||||||
|
|
||||||
defineEmits(['close']);
|
defineEmits(['close']);
|
||||||
|
|
||||||
@@ -677,4 +701,27 @@ input:checked + .toggle-slider:before {
|
|||||||
color: white;
|
color: white;
|
||||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Radio Group */
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"] {
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -42,38 +42,3 @@ const app = createApp(App)
|
|||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
// --- TEST: Auto Play Specific Song ---
|
|
||||||
import { usePlayerStore, PlayMode } from './stores/player'
|
|
||||||
import type { Song } from './types/song'
|
|
||||||
|
|
||||||
const playerStore = usePlayerStore()
|
|
||||||
|
|
||||||
// Set Single Mode
|
|
||||||
playerStore.playMode = PlayMode.Single;
|
|
||||||
|
|
||||||
const testSong: Song = {
|
|
||||||
id: '2716424334',
|
|
||||||
name: 'T氏の話を信じるな (feat. 初音ミク & 重音テト)',
|
|
||||||
artist: 'ピノキオピー、初音ミク、重音テト',
|
|
||||||
picUrl: 'http://p2.music.126.net/DmrEz0M4GwSeISIReCNNgw==/109951171319581237.jpg?param=130y130',
|
|
||||||
url: '',
|
|
||||||
duration: '02:43',
|
|
||||||
source: 'wy',
|
|
||||||
type: 'Remote'
|
|
||||||
};
|
|
||||||
const testSong2: Song = {
|
|
||||||
id: '1979007503',
|
|
||||||
name: '一半一半',
|
|
||||||
artist: '洛天依Official',
|
|
||||||
picUrl: 'http://p1.music.126.net/G02hs1vJYJir359bx8wGhg==/109951167851086939.jpg?param=130y130',
|
|
||||||
url: '',
|
|
||||||
duration: '02:43',
|
|
||||||
source: 'wy',
|
|
||||||
type: 'Remote'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto Play
|
|
||||||
playerStore.playSong(testSong).then(() => {
|
|
||||||
playerStore.setPlaylist([testSong, testSong2]);
|
|
||||||
});
|
|
||||||
@@ -46,9 +46,14 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
const isPlayerFullScreen = ref(false);
|
const isPlayerFullScreen = ref(false);
|
||||||
|
|
||||||
// Playlist State
|
// Playlist State
|
||||||
const playlist = ref<Song[]>([]);
|
const savedPlaylist = localStorage.getItem('qz-player-playlist');
|
||||||
const currentIndex = ref(-1);
|
const savedIndex = localStorage.getItem('qz-player-index');
|
||||||
|
|
||||||
|
const playlist = ref<Song[]>(savedPlaylist ? JSON.parse(savedPlaylist) : []);
|
||||||
|
const currentIndex = ref(savedIndex ? Number(savedIndex) : -1);
|
||||||
const playMode = ref<PlayMode>(PlayMode.List);
|
const playMode = ref<PlayMode>(PlayMode.List);
|
||||||
|
const savedAddMode = localStorage.getItem('qz-player-add-mode');
|
||||||
|
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
||||||
|
|
||||||
// Error Handling
|
// Error Handling
|
||||||
const playErrorCount = ref(0);
|
const playErrorCount = ref(0);
|
||||||
@@ -90,6 +95,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
const setPlaylist = async (list: any[], startIndex = 0) => {
|
const setPlaylist = async (list: any[], startIndex = 0) => {
|
||||||
|
// Legacy support or direct set
|
||||||
playlist.value = list;
|
playlist.value = list;
|
||||||
currentIndex.value = startIndex;
|
currentIndex.value = startIndex;
|
||||||
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
||||||
@@ -97,9 +103,34 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const playSong = async (song: Song) => {
|
const playFromList = async (song: Song, contextList: Song[]) => {
|
||||||
if (!song) return;
|
if (addListMode.value === 'replace') {
|
||||||
|
// Replace Mode: Replace playlist with context list and play song
|
||||||
|
// Find index in context list
|
||||||
|
const index = contextList.findIndex(s => s.id === song.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
await setPlaylist(contextList, index);
|
||||||
|
} else {
|
||||||
|
// Fallback: just play song if not found in list (shouldn't happen usually)
|
||||||
|
await setPlaylist([song], 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Append Mode: Add song to end of current playlist and play it
|
||||||
|
// Check if song already exists in playlist to avoid duplicates?
|
||||||
|
// User request says "append", usually means just add it.
|
||||||
|
// But if it's already there? Let's just add it to the end for now as requested "put clicked song to end".
|
||||||
|
|
||||||
|
// Push to playlist
|
||||||
|
playlist.value.push(song);
|
||||||
|
const newIndex = playlist.value.length - 1;
|
||||||
|
currentIndex.value = newIndex;
|
||||||
|
await playSong(song);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playSong = async (song: Song, autoPlay = true) => {
|
||||||
|
if (!song) return;
|
||||||
|
console.log(song);
|
||||||
currentSong.value = song;
|
currentSong.value = song;
|
||||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||||
if (foundIndex !== -1) {
|
if (foundIndex !== -1) {
|
||||||
@@ -108,7 +139,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
|
|
||||||
await activateDummyAudio();
|
await activateDummyAudio();
|
||||||
updateMediaSession(song);
|
updateMediaSession(song);
|
||||||
fetchLyrics(song);
|
// fetchLyrics(song);
|
||||||
|
|
||||||
// Get URL (Cache -> Network)
|
// Get URL (Cache -> Network)
|
||||||
let playUrl = song.url;
|
let playUrl = song.url;
|
||||||
@@ -120,22 +151,32 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playUrl) {
|
if (playUrl) {
|
||||||
console.log('Playing:', song.name);
|
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
||||||
// Reset retry flag for new playback attempt
|
// Reset retry flag for new playback attempt
|
||||||
hasRetriedWithFreshUrl.value = false;
|
hasRetriedWithFreshUrl.value = false;
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.qzplayer.load(playUrl);
|
await window.electronAPI.qzplayer.load(playUrl);
|
||||||
await window.electronAPI.qzplayer.play();
|
if (autoPlay) {
|
||||||
isPlaying.value = true;
|
await window.electronAPI.qzplayer.play();
|
||||||
|
isPlaying.value = true;
|
||||||
|
} else {
|
||||||
|
// Ensure it's paused if load auto-starts and we don't want it to
|
||||||
|
// But usually we just don't call play. If loadfile auto-plays, we might need to pause.
|
||||||
|
// For now, let's assume load doesn't force play or we can pause immediately.
|
||||||
|
// Actually, let's force pause just in case loadfile defaults to play.
|
||||||
|
await window.electronAPI.qzplayer.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
syncDummyAudioState(false);
|
||||||
|
}
|
||||||
song.url = playUrl;
|
song.url = playUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// IPC call failed (rare), handle sync error
|
// IPC call failed (rare), handle sync error
|
||||||
console.error("IPC Play request failed:", e);
|
console.error("IPC Play request failed:", e);
|
||||||
handlePlayError().then();
|
if (autoPlay) handlePlayError().then();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("Song has no URL");
|
console.warn("Song has no URL");
|
||||||
handlePlayError().then();
|
if (autoPlay) handlePlayError().then();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,7 +266,7 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
syncDummyAudioState(false);
|
syncDummyAudioState(false);
|
||||||
} else {
|
} else {
|
||||||
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`).then();
|
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`).then();
|
||||||
setTimeout(() => next(false), 500);
|
next(false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,6 +319,28 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
isPlayerFullScreen.value = !isPlayerFullScreen.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Persistence Listeners
|
||||||
|
watch(() => [playlist.value, currentIndex.value], () => {
|
||||||
|
localStorage.setItem('qz-player-playlist', JSON.stringify(playlist.value));
|
||||||
|
localStorage.setItem('qz-player-index', currentIndex.value.toString());
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch(addListMode, (newMode) => {
|
||||||
|
localStorage.setItem('qz-player-add-mode', newMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore initial state (without playing)
|
||||||
|
if (playlist.value.length > 0 && currentIndex.value >= 0 && currentIndex.value < playlist.value.length) {
|
||||||
|
const restoredSong = playlist.value[currentIndex.value];
|
||||||
|
if (restoredSong) {
|
||||||
|
// Use playSong with autoPlay=false to load the song into the engine
|
||||||
|
setTimeout(() => {
|
||||||
|
playSong(restoredSong, false);
|
||||||
|
fetchLyrics(restoredSong);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentSong,
|
currentSong,
|
||||||
@@ -299,6 +362,8 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
toggleMode,
|
toggleMode,
|
||||||
toggleFullScreen,
|
toggleFullScreen,
|
||||||
lyrics,
|
lyrics,
|
||||||
fetchLyrics
|
fetchLyrics,
|
||||||
|
addListMode,
|
||||||
|
playFromList
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -114,11 +114,14 @@ const coverGradient = computed(() => {
|
|||||||
// Mock Playlist Data
|
// Mock Playlist Data
|
||||||
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
title: `Song Title ${i + 1}`,
|
name: `Song Title ${i + 1}`,
|
||||||
artist: `Artist Name ${i + 1}`,
|
artist: `Artist Name ${i + 1}`,
|
||||||
album: `Album Mock`,
|
albumName: `Album Mock`,
|
||||||
duration: '03:30',
|
duration: '03:30',
|
||||||
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg'
|
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg',
|
||||||
|
picUrl: '',
|
||||||
|
source: 'local',
|
||||||
|
type: 'Local'
|
||||||
})));
|
})));
|
||||||
|
|
||||||
const songCount = computed(() => songs.value.length);
|
const songCount = computed(() => songs.value.length);
|
||||||
@@ -128,7 +131,7 @@ const handlePlayAll = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlaySong = (index: number) => {
|
const handlePlaySong = (index: number) => {
|
||||||
playerStore.setPlaylist(songs.value, index);
|
playerStore.playFromList(songs.value[index], songs.value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<div class="song-list-container" v-if="!loading && songs.length > 0">
|
<div class="error-state" v-if="!loading && error">
|
||||||
|
<div class="icon-box">
|
||||||
|
<Icon icon="lucide:alert-circle" class="state-icon" />
|
||||||
|
</div>
|
||||||
|
<p class="state-text">搜索出错了,请稍后重试</p>
|
||||||
|
<button class="retry-btn" @click="fetchData">
|
||||||
|
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="song-list-container" v-else-if="!loading && songs.length > 0">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<div class="col-index">#</div>
|
<div class="col-index">#</div>
|
||||||
<div class="col-title">标题</div>
|
<div class="col-title">标题</div>
|
||||||
@@ -79,8 +90,12 @@
|
|||||||
<span>正在搜索...</span>
|
<span>正在搜索...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-state" v-if="!loading && songs.length === 0">
|
<div class="empty-state" v-if="!loading && !error && songs.length === 0">
|
||||||
<span>未找到相关歌曲</span>
|
<span>未找到相关歌曲</span>
|
||||||
|
<button class="retry-btn" @click="fetchData">
|
||||||
|
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +118,10 @@ const currentPage = ref(1);
|
|||||||
const showSettings = ref(false);
|
const showSettings = ref(false);
|
||||||
const savedLimit = localStorage.getItem('qz-search-limit');
|
const savedLimit = localStorage.getItem('qz-search-limit');
|
||||||
const limit = ref(savedLimit ? Number(savedLimit) : 30);
|
const limit = ref(savedLimit ? Number(savedLimit) : 30);
|
||||||
|
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const error = ref(false);
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([]);
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const totalPages = computed(() => {
|
||||||
@@ -140,6 +157,7 @@ const fetchData = async () => {
|
|||||||
if (!query.value) return;
|
if (!query.value) return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
error.value = false;
|
||||||
songs.value = [];
|
songs.value = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +172,7 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Search failed", e);
|
console.error("Search failed", e);
|
||||||
|
error.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -165,7 +184,7 @@ const changePage = (page: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePlaySong = (index: number) => {
|
const handlePlaySong = (index: number) => {
|
||||||
playerStore.setPlaylist(songs.value, index);
|
playerStore.playFromList(songs.value[index], songs.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getHighlightRegex = (q: string) => {
|
const getHighlightRegex = (q: string) => {
|
||||||
@@ -593,18 +612,71 @@ watch(limit, (newLimit) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* States */
|
/* States */
|
||||||
.loading-state, .empty-state {
|
.loading-state, .empty-state, .error-state {
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state .icon-box {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white; /* Ensure text is readable on accent color */
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn .btn-icon {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||||
|
|||||||
Reference in New Issue
Block a user