forked from miao-moe/QZMusic_PC
fix: 优化&功能
- 播放列表记忆 - 播放列表添加模式设置项 - ProxyServer优化
This commit is contained in:
@@ -91,16 +91,7 @@ export class PluginSystem {
|
||||
error: 'Search not implemented'
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await this.plugin.musicSearch.search(query, page, limit)
|
||||
} catch (e: any) {
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
allPage: 0,
|
||||
error: e.message || 'Plugin search error'
|
||||
}
|
||||
}
|
||||
return await this.plugin.musicSearch.search(query, page, limit)
|
||||
}
|
||||
|
||||
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 请求
|
||||
* 修复版本:正确处理流的复制,确保下载和响应同时进行
|
||||
@@ -486,6 +506,9 @@ async function proxyAndCache(
|
||||
const requestedRange = req.headers.range;
|
||||
const isSeekRequest = requestedRange && !requestedRange.startsWith('bytes=0-');
|
||||
|
||||
// NEW: Cancel other downloads to enforce "Current Song Priority"
|
||||
cancelAllDownloadsExcept(cacheFilePath);
|
||||
|
||||
// 检查是否有正在进行的后台下载
|
||||
let task = downloadTasks.get(cacheFilePath);
|
||||
|
||||
@@ -565,14 +588,25 @@ async function streamAndCache(
|
||||
cacheFilePath: string,
|
||||
cacheKey: string
|
||||
): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
};
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
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) {
|
||||
throw { statusCode: response.status, statusText: response.statusText };
|
||||
@@ -592,6 +626,13 @@ async function streamAndCache(
|
||||
// @ts-ignore
|
||||
const nodeStream = Readable.fromWeb(response.body);
|
||||
nodeStream.pipe(res);
|
||||
|
||||
// Handle cleanup if aborted during pipe (though pipe handles some)
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
nodeStream.destroy();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
@@ -613,7 +654,7 @@ async function streamAndCache(
|
||||
currentSize: 0,
|
||||
totalSize: contentLength,
|
||||
contentType: contentType,
|
||||
abortController: null,
|
||||
abortController: controller,
|
||||
promise: null,
|
||||
waiters: [],
|
||||
};
|
||||
@@ -645,6 +686,8 @@ async function streamAndCache(
|
||||
res.on('close', () => {
|
||||
clientConnected = false;
|
||||
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) => {
|
||||
@@ -652,7 +695,9 @@ async function streamAndCache(
|
||||
task.currentSize += chunk.length;
|
||||
|
||||
// 写入文件(始终进行)
|
||||
fileStream.write(chunk);
|
||||
if (!fileStream.destroyed) {
|
||||
fileStream.write(chunk);
|
||||
}
|
||||
|
||||
// 写入响应(如果客户端还连接着)
|
||||
if (clientConnected && !res.writableEnded) {
|
||||
@@ -676,7 +721,11 @@ async function streamAndCache(
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
if (clientConnected && !res.headersSent) {
|
||||
@@ -934,6 +983,11 @@ export function startProxyServer(persistCache: boolean = true) {
|
||||
console.warn(`[Proxy] Proxy error (Attempt ${currentAttempt}/${maxAttempts}):`, e);
|
||||
|
||||
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...');
|
||||
urlCache.delete(cacheKey);
|
||||
|
||||
|
||||
@@ -133,6 +133,27 @@
|
||||
<!-- 播放设置 -->
|
||||
<div v-else-if="activeCategory === 'playback'" class="section">
|
||||
<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">
|
||||
<Icon icon="lucide:headphones" class="placeholder-icon" />
|
||||
<p>音质、淡入淡出等设置即将推出</p>
|
||||
@@ -168,6 +189,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onBeforeMount, nextTick } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { usePlayerStore } from '../stores/player';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
|
||||
defineEmits(['close']);
|
||||
|
||||
@@ -677,4 +701,27 @@ input:checked + .toggle-slider:before {
|
||||
color: white;
|
||||
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>
|
||||
|
||||
@@ -42,38 +42,3 @@ const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
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);
|
||||
|
||||
// Playlist State
|
||||
const playlist = ref<Song[]>([]);
|
||||
const currentIndex = ref(-1);
|
||||
const savedPlaylist = localStorage.getItem('qz-player-playlist');
|
||||
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 savedAddMode = localStorage.getItem('qz-player-add-mode');
|
||||
const addListMode = ref<'replace' | 'append'>((savedAddMode as 'replace' | 'append') || 'replace');
|
||||
|
||||
// Error Handling
|
||||
const playErrorCount = ref(0);
|
||||
@@ -90,6 +95,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
// --- Actions ---
|
||||
|
||||
const setPlaylist = async (list: any[], startIndex = 0) => {
|
||||
// Legacy support or direct set
|
||||
playlist.value = list;
|
||||
currentIndex.value = startIndex;
|
||||
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
|
||||
@@ -97,9 +103,34 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const playSong = async (song: Song) => {
|
||||
if (!song) return;
|
||||
const playFromList = async (song: Song, contextList: Song[]) => {
|
||||
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;
|
||||
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
|
||||
if (foundIndex !== -1) {
|
||||
@@ -108,7 +139,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
|
||||
await activateDummyAudio();
|
||||
updateMediaSession(song);
|
||||
fetchLyrics(song);
|
||||
// fetchLyrics(song);
|
||||
|
||||
// Get URL (Cache -> Network)
|
||||
let playUrl = song.url;
|
||||
@@ -120,22 +151,32 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
}
|
||||
|
||||
if (playUrl) {
|
||||
console.log('Playing:', song.name);
|
||||
console.log('Playing:', song.name, 'AutoPlay:', autoPlay);
|
||||
// Reset retry flag for new playback attempt
|
||||
hasRetriedWithFreshUrl.value = false;
|
||||
try {
|
||||
await window.electronAPI.qzplayer.load(playUrl);
|
||||
await window.electronAPI.qzplayer.play();
|
||||
isPlaying.value = true;
|
||||
if (autoPlay) {
|
||||
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;
|
||||
} catch (e) {
|
||||
// IPC call failed (rare), handle sync error
|
||||
console.error("IPC Play request failed:", e);
|
||||
handlePlayError().then();
|
||||
if (autoPlay) handlePlayError().then();
|
||||
}
|
||||
} else {
|
||||
console.warn("Song has no URL");
|
||||
handlePlayError().then();
|
||||
if (autoPlay) handlePlayError().then();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,7 +266,7 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
syncDummyAudioState(false);
|
||||
} else {
|
||||
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;
|
||||
};
|
||||
|
||||
// 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 {
|
||||
isPlaying,
|
||||
currentSong,
|
||||
@@ -299,6 +362,8 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
toggleMode,
|
||||
toggleFullScreen,
|
||||
lyrics,
|
||||
fetchLyrics
|
||||
fetchLyrics,
|
||||
addListMode,
|
||||
playFromList
|
||||
};
|
||||
});
|
||||
@@ -114,11 +114,14 @@ const coverGradient = computed(() => {
|
||||
// Mock Playlist Data
|
||||
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
title: `Song Title ${i + 1}`,
|
||||
name: `Song Title ${i + 1}`,
|
||||
artist: `Artist Name ${i + 1}`,
|
||||
album: `Album Mock`,
|
||||
albumName: `Album Mock`,
|
||||
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);
|
||||
@@ -128,7 +131,7 @@ const handlePlayAll = () => {
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.setPlaylist(songs.value, index);
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -23,7 +23,18 @@
|
||||
</div>
|
||||
</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="col-index">#</div>
|
||||
<div class="col-title">标题</div>
|
||||
@@ -79,8 +90,12 @@
|
||||
<span>正在搜索...</span>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" v-if="!loading && songs.length === 0">
|
||||
<div class="empty-state" v-if="!loading && !error && songs.length === 0">
|
||||
<span>未找到相关歌曲</span>
|
||||
<button class="retry-btn" @click="fetchData">
|
||||
<Icon icon="lucide:refresh-cw" class="btn-icon" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,8 +118,10 @@ const currentPage = ref(1);
|
||||
const showSettings = ref(false);
|
||||
const savedLimit = localStorage.getItem('qz-search-limit');
|
||||
const limit = ref(savedLimit ? Number(savedLimit) : 30);
|
||||
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(false);
|
||||
const songs = ref<Song[]>([]);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
@@ -140,6 +157,7 @@ const fetchData = async () => {
|
||||
if (!query.value) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
songs.value = [];
|
||||
|
||||
try {
|
||||
@@ -154,6 +172,7 @@ const fetchData = async () => {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Search failed", e);
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -165,7 +184,7 @@ const changePage = (page: number) => {
|
||||
};
|
||||
|
||||
const handlePlaySong = (index: number) => {
|
||||
playerStore.setPlaylist(songs.value, index);
|
||||
playerStore.playFromList(songs.value[index], songs.value);
|
||||
};
|
||||
|
||||
const getHighlightRegex = (q: string) => {
|
||||
@@ -593,18 +612,71 @@ watch(limit, (newLimit) => {
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading-state, .empty-state {
|
||||
.loading-state, .empty-state, .error-state {
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
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); } }
|
||||
|
||||
Reference in New Issue
Block a user