mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-01 20:42:50 +08:00
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:
1 parent
94a0df8391
commit
d84f23c2b2
4 files changed
+167
-17
No files matched your search
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+15
-12
@@ -19,6 +19,7 @@
|
|||||||
<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-inner" :style="{ transform: `translateY(${scrollOffset}px)`, transition: 'transform 0.6s cubic-bezier(0.25, 0.1, 0.25, 1)' }">
|
||||||
<div class="lyrics-spacer" />
|
<div class="lyrics-spacer" />
|
||||||
<div
|
<div
|
||||||
v-for="(line, i) in lines"
|
v-for="(line, i) in lines"
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="no-song">
|
<div v-else class="no-song">
|
||||||
当前没有正在播放的歌曲
|
当前没有正在播放的歌曲
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user