fix: resolve multiple UI issues in player, playlist, lyrics, and queue

- Playlist: add console.error in catch block so load failures are visible
- Player: add progress bar with requestAnimationFrame and time display
- Player: extract toggleQueue function with debug logging for queue panel
- WebSocket: fetch queue on stateChange when not included in event payload
- Lyrics: use CSS transform scrolling and reduce sync to 500ms to fix flicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 02:34:48 +08:00
1 parent 94a0df8391
commit d84f23c2b2
4 files changed
+179 -29

No files matched your search

+142 -2
View File
@@ -1,5 +1,25 @@
<template> <template>
<div class="player-bar frosted-glass" v-if="currentSong"> <div class="player-bar frosted-glass" v-if="currentSong">
<!-- Progress bar -->
<div
class="progress-bar-container"
ref="progressBarRef"
@mousedown="onProgressMouseDown"
@mousemove="onProgressHover"
@mouseleave="progressTooltipVisible = false"
>
<div class="progress-bar-bg">
<div class="progress-bar-fill" :style="{ width: progressPercent + '%' }" />
</div>
<div
v-if="progressTooltipVisible"
class="progress-tooltip"
:style="{ left: progressTooltipX + 'px' }"
>
{{ progressTooltipTime }}
</div>
</div>
<div class="player-left"> <div class="player-left">
<CoverArt :url="currentSong.coverUrl" :size="40" /> <CoverArt :url="currentSong.coverUrl" :size="40" />
<div class="song-info"> <div class="song-info">
@@ -9,6 +29,7 @@
</div> </div>
<div class="player-center"> <div class="player-center">
<span class="time-display time-current">{{ formatTime(currentElapsed) }}</span>
<button class="control-btn" @click="store.prev()"> <button class="control-btn" @click="store.prev()">
<Icon icon="mdi:skip-previous" /> <Icon icon="mdi:skip-previous" />
</button> </button>
@@ -22,6 +43,7 @@
<Icon :icon="modeIcon" /> <Icon :icon="modeIcon" />
<span class="mode-label">{{ modeLabel }}</span> <span class="mode-label">{{ modeLabel }}</span>
</button> </button>
<span class="time-display time-total">{{ formatTime(currentSong?.duration ?? 0) }}</span>
</div> </div>
<div class="player-right"> <div class="player-right">
@@ -34,7 +56,7 @@
@input="onVolumeChange" @input="onVolumeChange"
class="volume-slider" class="volume-slider"
/> />
<button class="control-btn" @click="showQueue = !showQueue"> <button class="control-btn" @click="toggleQueue">
<Icon icon="mdi:playlist-music" /> <Icon icon="mdi:playlist-music" />
</button> </button>
<RouterLink to="/lyrics" class="control-btn lyrics-btn"> <RouterLink to="/lyrics" class="control-btn lyrics-btn">
@@ -46,7 +68,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref, onMounted, onUnmounted } from 'vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.js'; import { usePlayerStore } from '../stores/player.js';
import CoverArt from './CoverArt.vue'; import CoverArt from './CoverArt.vue';
@@ -58,6 +80,65 @@ const store = usePlayerStore();
const activeBot = computed(() => store.activeBot); const activeBot = computed(() => store.activeBot);
const currentSong = computed(() => store.currentSong); const currentSong = computed(() => store.currentSong);
// Progress bar state
const progressBarRef = ref<HTMLElement | null>(null);
const currentElapsed = ref(0);
const progressPercent = ref(0);
const progressTooltipVisible = ref(false);
const progressTooltipX = ref(0);
const progressTooltipTime = ref('0:00');
let rafId: number | null = null;
function formatTime(seconds: number): string {
if (!seconds || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
function updateProgress() {
const elapsed = store.elapsed;
const duration = currentSong.value?.duration ?? 0;
currentElapsed.value = elapsed;
progressPercent.value = duration > 0 ? Math.min((elapsed / duration) * 100, 100) : 0;
rafId = requestAnimationFrame(updateProgress);
}
function onProgressMouseDown(e: MouseEvent) {
const bar = progressBarRef.value;
if (!bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const duration = currentSong.value?.duration ?? 0;
// Visual-only seek (server-side seeking not supported)
progressPercent.value = ratio * 100;
progressTooltipTime.value = formatTime(ratio * duration);
}
function onProgressHover(e: MouseEvent) {
const bar = progressBarRef.value;
if (!bar) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const duration = currentSong.value?.duration ?? 0;
progressTooltipVisible.value = true;
progressTooltipX.value = e.clientX - rect.left;
progressTooltipTime.value = formatTime(ratio * duration);
}
onMounted(() => {
rafId = requestAnimationFrame(updateProgress);
});
onUnmounted(() => {
if (rafId !== null) cancelAnimationFrame(rafId);
});
function toggleQueue() {
showQueue.value = !showQueue.value;
console.log('Queue panel toggled:', showQueue.value);
}
function togglePlay() { function togglePlay() {
if (store.isPlaying) { if (store.isPlaying) {
store.pause(); store.pause();
@@ -110,6 +191,65 @@ function cycleMode() {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.progress-bar-container {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 8px;
cursor: pointer;
z-index: 101;
display: flex;
align-items: flex-end;
&:hover {
.progress-bar-bg { height: 4px; }
.progress-bar-fill { height: 4px; }
}
}
.progress-bar-bg {
width: 100%;
height: 2px;
background: var(--border-color);
transition: height 0.15s ease;
position: relative;
}
.progress-bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary);
border-radius: 0 1px 1px 0;
transition: height 0.15s ease;
}
.progress-tooltip {
position: absolute;
top: -28px;
transform: translateX(-50%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 2px 8px;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
pointer-events: none;
}
.time-display {
font-size: 11px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
min-width: 36px;
}
.time-current { text-align: right; }
.time-total { text-align: left; }
.player-left { .player-left {
display: flex; display: flex;
align-items: center; align-items: center;
+6 -1
View File
@@ -29,7 +29,12 @@ export function useWebSocket() {
break; break;
case 'stateChange': case 'stateChange':
store.updateBotStatus(data.botId, data.status); store.updateBotStatus(data.botId, data.status);
if (data.queue) store.setQueue(data.queue); if (data.queue) {
store.setQueue(data.queue);
} else {
// Queue not included in event; refresh it
store.fetchQueue();
}
break; break;
case 'botConnected': case 'botConnected':
store.updateBotStatus(data.botId, data.status); store.updateBotStatus(data.botId, data.status);
+27 -24
View File
@@ -19,19 +19,21 @@
<div v-if="loading" class="lyrics-loading">加载歌词中...</div> <div v-if="loading" class="lyrics-loading">加载歌词中...</div>
<div v-else-if="lines.length === 0" class="lyrics-empty">暂无歌词</div> <div v-else-if="lines.length === 0" class="lyrics-empty">暂无歌词</div>
<div v-else class="lyrics-scroll" ref="scrollContainer"> <div v-else class="lyrics-scroll" ref="scrollContainer">
<div class="lyrics-spacer" /> <div class="lyrics-inner" :style="{ transform: `translateY(${scrollOffset}px)`, transition: 'transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1)' }">
<div <div class="lyrics-spacer" />
v-for="(line, i) in lines" <div
:key="i" v-for="(line, i) in lines"
:ref="el => { if (el) lineRefs[i] = el as HTMLElement }" :key="i"
class="lyrics-line" :ref="el => { if (el) lineRefs[i] = el as HTMLElement }"
:class="{ active: i === activeLine }" class="lyrics-line"
@click="seekToLine(i)" :class="{ active: i === activeLine }"
> @click="seekToLine(i)"
<div class="lyrics-text">{{ line.text }}</div> >
<div v-if="line.translation" class="lyrics-translation">{{ line.translation }}</div> <div class="lyrics-text">{{ line.text }}</div>
<div v-if="line.translation" class="lyrics-translation">{{ line.translation }}</div>
</div>
<div class="lyrics-spacer" />
</div> </div>
<div class="lyrics-spacer" />
</div> </div>
</div> </div>
</div> </div>
@@ -63,6 +65,7 @@ const activeLine = ref(-1);
const loading = ref(false); const loading = ref(false);
const scrollContainer = ref<HTMLElement | null>(null); const scrollContainer = ref<HTMLElement | null>(null);
const lineRefs = ref<Record<number, HTMLElement>>({}); const lineRefs = ref<Record<number, HTMLElement>>({});
const scrollOffset = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null; let syncTimer: ReturnType<typeof setInterval> | null = null;
const bgStyle = computed(() => { const bgStyle = computed(() => {
@@ -113,23 +116,19 @@ function scrollToActiveLine(idx: number) {
const container = scrollContainer.value; const container = scrollContainer.value;
if (!el || !container) return; if (!el || !container) return;
// Calculate target scroll position to center the active line // Use CSS transform for smooth, jank-free scrolling
const containerHeight = container.clientHeight; const containerHeight = container.clientHeight;
const lineTop = el.offsetTop; const lineTop = el.offsetTop;
const lineHeight = el.offsetHeight; const lineHeight = el.offsetHeight;
const targetScroll = lineTop - containerHeight / 2 + lineHeight / 2; const targetOffset = -(lineTop - containerHeight / 2 + lineHeight / 2);
scrollOffset.value = targetOffset;
// Smooth scroll using CSS scroll-behavior or manual animation
container.scrollTo({
top: targetScroll,
behavior: 'smooth',
});
} }
function syncLyrics() { function syncLyrics() {
if (!store.isPlaying || lines.value.length === 0) return; if (!store.isPlaying || lines.value.length === 0) return;
const elapsed = store.elapsed; const elapsed = store.elapsed;
const idx = findActiveLine(elapsed); const idx = findActiveLine(elapsed);
// Only update when the active line actually changes
if (idx !== activeLine.value && idx >= 0) { if (idx !== activeLine.value && idx >= 0) {
activeLine.value = idx; activeLine.value = idx;
scrollToActiveLine(idx); scrollToActiveLine(idx);
@@ -139,11 +138,12 @@ function syncLyrics() {
function seekToLine(index: number) { function seekToLine(index: number) {
// Can't actually seek server-side playback, just highlight // Can't actually seek server-side playback, just highlight
activeLine.value = index; activeLine.value = index;
scrollToActiveLine(index);
} }
function startSync() { function startSync() {
stopSync(); stopSync();
syncTimer = setInterval(syncLyrics, 200); syncTimer = setInterval(syncLyrics, 500);
} }
function stopSync() { function stopSync() {
@@ -260,9 +260,12 @@ onUnmounted(() => {
.lyrics-scroll { .lyrics-scroll {
height: 100%; height: 100%;
overflow-y: auto; overflow: hidden;
scrollbar-width: none; position: relative;
&::-webkit-scrollbar { display: none; } }
.lyrics-inner {
will-change: transform;
} }
.lyrics-spacer { .lyrics-spacer {
+4 -2
View File
@@ -96,8 +96,10 @@ onMounted(async () => {
]); ]);
playlist.value = detailRes.data.playlist; playlist.value = detailRes.data.playlist;
songs.value = songsRes.data.songs; songs.value = songsRes.data.songs;
} catch { } catch (err) {
// Ignore if API not ready console.error('Failed to load playlist:', err);
playlist.value = null;
songs.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
} }