mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: make frontend fully support multiple independent bots
Store queue and timing state per-bot instead of globally, move bot selector from Home page into Navbar dropdown, show bot name badge in the player bar, and route WebSocket stateChange queues per-bot. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
75d359f65f
commit
bc9b2366d0
6 files changed
+316
-95
No files matched your search
@@ -9,9 +9,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<div v-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
|
<!-- Multi-bot selector -->
|
||||||
|
<div v-if="store.bots.length > 1" class="bot-selector" ref="selectorRef">
|
||||||
|
<button class="bot-selector-btn" @click="dropdownOpen = !dropdownOpen">
|
||||||
|
<span class="bot-dot" :class="{ online: activeBot?.connected }" />
|
||||||
|
<span class="bot-selector-name">{{ activeBot?.name ?? '选择机器人' }}</span>
|
||||||
|
<Icon icon="mdi:chevron-down" class="bot-chevron" :class="{ rotated: dropdownOpen }" />
|
||||||
|
</button>
|
||||||
|
<div v-if="dropdownOpen" class="bot-dropdown">
|
||||||
|
<button
|
||||||
|
v-for="bot in store.bots"
|
||||||
|
:key="bot.id"
|
||||||
|
class="bot-dropdown-item"
|
||||||
|
:class="{ active: bot.id === store.activeBotId }"
|
||||||
|
@click="selectBot(bot.id)"
|
||||||
|
>
|
||||||
|
<span class="bot-dot" :class="{ online: bot.connected }" />
|
||||||
|
<span class="bot-dropdown-name">{{ bot.name }}</span>
|
||||||
|
<span v-if="bot.playing && !bot.paused" class="bot-playing-badge">播放中</span>
|
||||||
|
<span v-else-if="bot.paused" class="bot-paused-badge">已暂停</span>
|
||||||
|
<span v-else-if="bot.connected" class="bot-idle-badge">空闲</span>
|
||||||
|
<span v-else class="bot-offline-badge">离线</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single bot status (original behavior) -->
|
||||||
|
<div v-else-if="activeBot" class="bot-status" :class="{ online: activeBot.connected }">
|
||||||
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
|
{{ activeBot.name }} {{ activeBot.connected ? '在线' : '离线' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RouterLink to="/settings" class="settings-btn">
|
<RouterLink to="/settings" class="settings-btn">
|
||||||
<Icon icon="mdi:cog" />
|
<Icon icon="mdi:cog" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -20,12 +47,33 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } 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';
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
|
const dropdownOpen = ref(false);
|
||||||
|
const selectorRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function selectBot(id: string) {
|
||||||
|
store.setActiveBotId(id);
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onClickOutside);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onClickOutside);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -89,6 +137,130 @@ const activeBot = computed(() => store.activeBot);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bot selector dropdown */
|
||||||
|
.bot-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--hover-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-chevron {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-selector-name {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(51, 94, 234, 0.12);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-dropdown-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-playing-badge,
|
||||||
|
.bot-paused-badge,
|
||||||
|
.bot-idle-badge,
|
||||||
|
.bot-offline-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-playing-badge {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-paused-badge {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-idle-badge {
|
||||||
|
background: rgba(51, 94, 234, 0.12);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-offline-badge {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-btn {
|
.settings-btn {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
||||||
<div class="song-info">
|
<div class="song-info">
|
||||||
<div class="song-name">{{ currentSong.name }}</div>
|
<div class="song-name">{{ currentSong.name }}</div>
|
||||||
<div class="song-artist">{{ currentSong.artist }}</div>
|
<div class="song-artist">
|
||||||
|
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
|
||||||
|
{{ currentSong.artist }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ const showQueue = ref(false);
|
|||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
const activeBot = computed(() => store.activeBot);
|
const activeBot = computed(() => store.activeBot);
|
||||||
const currentSong = computed(() => store.currentSong);
|
const currentSong = computed(() => store.currentSong);
|
||||||
|
const showBotBadge = computed(() => store.bots.length > 1);
|
||||||
|
|
||||||
function toggleLyrics() {
|
function toggleLyrics() {
|
||||||
if (route.path === '/lyrics') {
|
if (route.path === '/lyrics') {
|
||||||
@@ -315,6 +319,22 @@ function cycleMode() {
|
|||||||
.song-artist {
|
.song-artist {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 5px;
|
||||||
|
background: rgba(51, 94, 234, 0.15);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-center {
|
.player-center {
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
<div class="queue-panel" :class="{ open }">
|
<div class="queue-panel" :class="{ open }">
|
||||||
<div class="queue-header">
|
<div class="queue-header">
|
||||||
<h3 class="queue-title">播放队列</h3>
|
<h3 class="queue-title">播放队列</h3>
|
||||||
<span class="queue-count">{{ store.queue.length }} 首</span>
|
<span class="queue-count">{{ botQueue.length }} 首</span>
|
||||||
<button class="close-btn" @click="$emit('close')">
|
<button class="close-btn" @click="$emit('close')">
|
||||||
<Icon icon="mdi:close" />
|
<Icon icon="mdi:close" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="store.queue.length === 0" class="queue-empty">
|
<div v-if="botQueue.length === 0" class="queue-empty">
|
||||||
队列为空
|
队列为空
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="queue-list">
|
<div v-else class="queue-list">
|
||||||
<div
|
<div
|
||||||
v-for="(song, i) in store.queue"
|
v-for="(song, i) in botQueue"
|
||||||
:key="`${song.id}-${i}`"
|
:key="`${song.id}-${i}`"
|
||||||
class="queue-item"
|
class="queue-item"
|
||||||
:class="{ active: store.currentSong?.id === song.id }"
|
:class="{ active: store.currentSong?.id === song.id }"
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue';
|
import { watch, computed } from 'vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { usePlayerStore } from '../stores/player.js';
|
import { usePlayerStore } from '../stores/player.js';
|
||||||
@@ -49,6 +49,7 @@ defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = usePlayerStore();
|
const store = usePlayerStore();
|
||||||
|
const botQueue = computed(() => store.queue);
|
||||||
|
|
||||||
// Fetch queue when panel opens
|
// Fetch queue when panel opens
|
||||||
watch(() => props.open, (isOpen) => {
|
watch(() => props.open, (isOpen) => {
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ export function useWebSocket() {
|
|||||||
case 'stateChange':
|
case 'stateChange':
|
||||||
store.updateBotStatus(data.botId, data.status);
|
store.updateBotStatus(data.botId, data.status);
|
||||||
if (data.queue) {
|
if (data.queue) {
|
||||||
store.setQueue(data.queue);
|
store.setQueue(data.botId, data.queue);
|
||||||
} else {
|
} else {
|
||||||
// Queue not included in event; refresh it
|
// Queue not included in event; refresh for this specific bot
|
||||||
store.fetchQueue();
|
store.fetchQueueForBot(data.botId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'botConnected':
|
case 'botConnected':
|
||||||
|
|||||||
+113
-50
@@ -32,20 +32,28 @@ export interface PlaylistItem {
|
|||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimingState {
|
||||||
|
serverElapsed: number;
|
||||||
|
serverSyncTime: number;
|
||||||
|
wasPlaying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const HOME_CACHE_TTL = 5 * 60 * 1000;
|
const HOME_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function defaultTiming(): TimingState {
|
||||||
|
return { serverElapsed: 0, serverSyncTime: 0, wasPlaying: false };
|
||||||
|
}
|
||||||
|
|
||||||
export const usePlayerStore = defineStore('player', {
|
export const usePlayerStore = defineStore('player', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
bots: [] as BotStatus[],
|
bots: [] as BotStatus[],
|
||||||
activeBotId: null as string | null,
|
activeBotId: null as string | null,
|
||||||
queue: [] as Song[],
|
/** Per-bot queues keyed by botId */
|
||||||
|
queues: {} as Record<string, Song[]>,
|
||||||
|
/** Per-bot timing state keyed by botId */
|
||||||
|
timings: {} as Record<string, TimingState>,
|
||||||
theme: 'dark' as 'dark' | 'light',
|
theme: 'dark' as 'dark' | 'light',
|
||||||
|
|
||||||
// Server-synced elapsed time (ground truth)
|
|
||||||
serverElapsed: 0, // last known server elapsed (seconds)
|
|
||||||
serverSyncTime: 0, // Date.now() when we got serverElapsed
|
|
||||||
wasPlaying: false, // was playing at last sync
|
|
||||||
|
|
||||||
// Home page cache
|
// Home page cache
|
||||||
recommendPlaylists: [] as PlaylistItem[],
|
recommendPlaylists: [] as PlaylistItem[],
|
||||||
dailySongs: [] as Song[],
|
dailySongs: [] as Song[],
|
||||||
@@ -67,19 +75,47 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
isPaused(): boolean {
|
isPaused(): boolean {
|
||||||
return this.activeBot?.paused ?? false;
|
return this.activeBot?.paused ?? false;
|
||||||
},
|
},
|
||||||
/** Interpolated elapsed: serverElapsed + time since last sync (if playing) */
|
/** Queue for the currently active bot */
|
||||||
|
queue(): Song[] {
|
||||||
|
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||||
|
if (!botId) return [];
|
||||||
|
return this.queues[botId] ?? [];
|
||||||
|
},
|
||||||
|
/** Interpolated elapsed for the active bot */
|
||||||
elapsed(): number {
|
elapsed(): number {
|
||||||
if (!this.activeBot?.currentSong) return 0;
|
const botId = this.activeBotId ?? this.bots[0]?.id;
|
||||||
|
if (!botId || !this.activeBot?.currentSong) return 0;
|
||||||
|
const timing = this.timings[botId] ?? defaultTiming();
|
||||||
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
const maxDuration = this.activeBot.currentSong.duration || Infinity;
|
||||||
if (!this.wasPlaying || this.serverSyncTime === 0) return Math.min(this.serverElapsed, maxDuration);
|
if (!timing.wasPlaying || timing.serverSyncTime === 0) return Math.min(timing.serverElapsed, maxDuration);
|
||||||
if (this.isPaused) return Math.min(this.serverElapsed, maxDuration);
|
if (this.isPaused) return Math.min(timing.serverElapsed, maxDuration);
|
||||||
return Math.min(this.serverElapsed + (Date.now() - this.serverSyncTime) / 1000, maxDuration);
|
return Math.min(timing.serverElapsed + (Date.now() - timing.serverSyncTime) / 1000, maxDuration);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
_getTiming(botId: string): TimingState {
|
||||||
|
if (!this.timings[botId]) {
|
||||||
|
this.timings[botId] = defaultTiming();
|
||||||
|
}
|
||||||
|
return this.timings[botId];
|
||||||
|
},
|
||||||
|
|
||||||
|
_setTiming(botId: string, partial: Partial<TimingState>) {
|
||||||
|
const current = this._getTiming(botId);
|
||||||
|
this.timings[botId] = { ...current, ...partial };
|
||||||
|
},
|
||||||
|
|
||||||
|
getQueueForBot(botId: string): Song[] {
|
||||||
|
return this.queues[botId] ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
setActiveBotId(id: string) {
|
setActiveBotId(id: string) {
|
||||||
this.activeBotId = id;
|
this.activeBotId = id;
|
||||||
|
// Fetch queue for newly active bot if we don't have it yet
|
||||||
|
if (!this.queues[id]) {
|
||||||
|
this.fetchQueue();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateBotStatus(botId: string, status: BotStatus) {
|
updateBotStatus(botId: string, status: BotStatus) {
|
||||||
@@ -93,29 +129,33 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
this.bots.push(status);
|
this.bots.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (botId !== (this.activeBotId ?? this.bots[0]?.id)) return;
|
// Sync elapsed from server status — always per-bot
|
||||||
|
|
||||||
// Sync elapsed from server status
|
|
||||||
if (status.elapsed !== undefined) {
|
if (status.elapsed !== undefined) {
|
||||||
this.serverElapsed = status.elapsed;
|
this._setTiming(botId, {
|
||||||
this.serverSyncTime = Date.now();
|
serverElapsed: status.elapsed,
|
||||||
this.wasPlaying = status.playing && !status.paused;
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: status.playing && !status.paused,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Song changed — reset
|
// Song changed — reset timing for this bot
|
||||||
if (status.currentSong?.id !== prevSongId) {
|
if (status.currentSong?.id !== prevSongId) {
|
||||||
this.serverElapsed = status.elapsed ?? 0;
|
this._setTiming(botId, {
|
||||||
this.serverSyncTime = Date.now();
|
serverElapsed: status.elapsed ?? 0,
|
||||||
this.wasPlaying = status.playing && !status.paused;
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: status.playing && !status.paused,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
removeBotStatus(botId: string) {
|
removeBotStatus(botId: string) {
|
||||||
this.bots = this.bots.filter((b) => b.id !== botId);
|
this.bots = this.bots.filter((b) => b.id !== botId);
|
||||||
|
delete this.queues[botId];
|
||||||
|
delete this.timings[botId];
|
||||||
},
|
},
|
||||||
|
|
||||||
setQueue(queue: Song[]) {
|
setQueue(botId: string, queue: Song[]) {
|
||||||
this.queue = queue;
|
this.queues[botId] = queue;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleTheme() {
|
toggleTheme() {
|
||||||
@@ -134,23 +174,28 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (!this.activeBotId && this.bots.length > 0) {
|
if (!this.activeBotId && this.bots.length > 0) {
|
||||||
this.activeBotId = this.bots[0].id;
|
this.activeBotId = this.bots[0].id;
|
||||||
}
|
}
|
||||||
// Sync elapsed from first status
|
// Sync elapsed from each bot's status
|
||||||
const bot = this.activeBot;
|
for (const bot of this.bots) {
|
||||||
if (bot?.elapsed !== undefined) {
|
if (bot.elapsed !== undefined) {
|
||||||
this.serverElapsed = bot.elapsed;
|
this._setTiming(bot.id, {
|
||||||
this.serverSyncTime = Date.now();
|
serverElapsed: bot.elapsed,
|
||||||
this.wasPlaying = bot.playing && !bot.paused;
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: bot.playing && !bot.paused,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Poll server for real elapsed time */
|
/** Poll server for real elapsed time for active bot */
|
||||||
async syncElapsed() {
|
async syncElapsed() {
|
||||||
if (!this.activeBotId || !this.isPlaying) return;
|
if (!this.activeBotId || !this.isPlaying) return;
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`/api/player/${this.activeBotId}/elapsed`);
|
const res = await axios.get(`/api/player/${this.activeBotId}/elapsed`);
|
||||||
this.serverElapsed = res.data.elapsed;
|
this._setTiming(this.activeBotId, {
|
||||||
this.serverSyncTime = Date.now();
|
serverElapsed: res.data.elapsed,
|
||||||
this.wasPlaying = true;
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: true,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -160,15 +205,27 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`/api/player/${this.activeBotId}/queue`);
|
const res = await axios.get(`/api/player/${this.activeBotId}/queue`);
|
||||||
this.queue = res.data.queue ?? [];
|
this.queues[this.activeBotId] = res.data.queue ?? [];
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchQueueForBot(botId: string) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/player/${botId}/queue`);
|
||||||
|
this.queues[botId] = res.data.queue ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_syncAfterAction() {
|
_syncAfterAction() {
|
||||||
this.serverSyncTime = Date.now();
|
if (!this.activeBotId) return;
|
||||||
this.wasPlaying = true;
|
this._setTiming(this.activeBotId, {
|
||||||
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: true,
|
||||||
|
});
|
||||||
// Sync from server after a short delay for accuracy
|
// Sync from server after a short delay for accuracy
|
||||||
setTimeout(() => this.syncElapsed(), 500);
|
setTimeout(() => this.syncElapsed(), 500);
|
||||||
},
|
},
|
||||||
@@ -176,21 +233,21 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
async playAtIndex(index: number) {
|
async playAtIndex(index: number) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-at`, { index });
|
await axios.post(`/api/player/${this.activeBotId}/play-at`, { index });
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async play(query: string, platform = 'netease') {
|
async play(query: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play`, { query, platform });
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async playById(songId: string, platform = 'netease') {
|
async playById(songId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play-by-id`, { songId, platform });
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -207,52 +264,58 @@ export const usePlayerStore = defineStore('player', {
|
|||||||
async playPlaylist(playlistId: string, platform = 'netease') {
|
async playPlaylist(playlistId: string, platform = 'netease') {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
await axios.post(`/api/player/${this.activeBotId}/play-playlist`, { playlistId, platform });
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async pause() {
|
async pause() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
// Freeze elapsed at current interpolated value
|
// Freeze elapsed at current interpolated value
|
||||||
this.serverElapsed = this.elapsed;
|
this._setTiming(this.activeBotId, {
|
||||||
this.wasPlaying = false;
|
serverElapsed: this.elapsed,
|
||||||
|
wasPlaying: false,
|
||||||
|
});
|
||||||
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
await axios.post(`/api/player/${this.activeBotId}/pause`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resume() {
|
async resume() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/resume`);
|
await axios.post(`/api/player/${this.activeBotId}/resume`);
|
||||||
this.serverSyncTime = Date.now();
|
this._setTiming(this.activeBotId, {
|
||||||
this.wasPlaying = true;
|
serverSyncTime: Date.now(),
|
||||||
|
wasPlaying: true,
|
||||||
|
});
|
||||||
setTimeout(() => this.syncElapsed(), 300);
|
setTimeout(() => this.syncElapsed(), 300);
|
||||||
},
|
},
|
||||||
|
|
||||||
async next() {
|
async next() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/next`);
|
await axios.post(`/api/player/${this.activeBotId}/next`);
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async prev() {
|
async prev() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/prev`);
|
await axios.post(`/api/player/${this.activeBotId}/prev`);
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, { serverElapsed: 0 });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
async stop() {
|
async stop() {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/stop`);
|
await axios.post(`/api/player/${this.activeBotId}/stop`);
|
||||||
this.serverElapsed = 0;
|
this._setTiming(this.activeBotId, {
|
||||||
this.serverSyncTime = 0;
|
serverElapsed: 0,
|
||||||
this.wasPlaying = false;
|
serverSyncTime: 0,
|
||||||
|
wasPlaying: false,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async seek(position: number) {
|
async seek(position: number) {
|
||||||
if (!this.activeBotId) return;
|
if (!this.activeBotId) return;
|
||||||
await axios.post(`/api/player/${this.activeBotId}/seek`, { position });
|
await axios.post(`/api/player/${this.activeBotId}/seek`, { position });
|
||||||
this.serverElapsed = position;
|
this._setTiming(this.activeBotId, { serverElapsed: position });
|
||||||
this._syncAfterAction();
|
this._syncAfterAction();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<!-- Bot Instance Selector -->
|
|
||||||
<div class="bot-selector" v-if="store.bots.length > 1">
|
|
||||||
<button
|
|
||||||
v-for="bot in store.bots"
|
|
||||||
:key="bot.id"
|
|
||||||
class="bot-tab"
|
|
||||||
:class="{ active: bot.id === store.activeBotId }"
|
|
||||||
@click="store.setActiveBotId(bot.id)"
|
|
||||||
>
|
|
||||||
{{ bot.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="search-bar" @click="$router.push('/search')">
|
<div class="search-bar" @click="$router.push('/search')">
|
||||||
<Icon icon="mdi:magnify" class="search-icon" />
|
<Icon icon="mdi:magnify" class="search-icon" />
|
||||||
@@ -166,28 +153,6 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.bot-selector {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bot-tab {
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--hover-bg);
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: all var(--transition-fast);
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in new issue
Block a user