fix: 优化&功能

- 播放列表记忆
- 播放列表添加模式设置项
- ProxyServer优化
This commit is contained in:
lqtmcstudio
2026-02-06 17:01:45 +08:00
parent 199202de62
commit 47689f23a4
7 changed files with 270 additions and 73 deletions

View File

@@ -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'
}
}
}
async getLyric(id: string): Promise<any> {

View File

@@ -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, {
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;
// 写入文件(始终进行)
if (!fileStream.destroyed) {
fileStream.write(chunk);
}
// 写入响应(如果客户端还连接着)
if (clientConnected && !res.writableEnded) {
@@ -676,7 +721,11 @@ async function streamAndCache(
});
nodeStream.on('error', (err: Error) => {
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);

View File

@@ -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>

View File

@@ -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]);
});

View File

@@ -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);
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
};
});

View File

@@ -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>

View File

@@ -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); } }