mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-06 06:52:49 +08:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
e4eea8276a
26 files changed
+1921
-67
No files matched your search
@@ -7,6 +7,7 @@
|
||||
<Player />
|
||||
<Toast />
|
||||
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
|
||||
<BilibiliPartModal />
|
||||
|
||||
<!-- Mobile mini player -->
|
||||
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
|
||||
@@ -102,6 +103,7 @@ import Player from './components/Player.vue';
|
||||
import CoverArt from './components/CoverArt.vue';
|
||||
import Toast from './components/Toast.vue';
|
||||
import Queue from './components/Queue.vue';
|
||||
import BilibiliPartModal from './components/BilibiliPartModal.vue';
|
||||
|
||||
const playerStore = usePlayerStore();
|
||||
const session = useSession();
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
|
||||
<div class="edit-modal">
|
||||
<h3 class="modal-title">选择分P</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>视频名称</label>
|
||||
<div class="video-info-box">
|
||||
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
|
||||
<div class="video-meta">
|
||||
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
|
||||
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>分P列表</label>
|
||||
<div class="parts-list">
|
||||
<div
|
||||
v-for="part in modal.parts"
|
||||
:key="part.part"
|
||||
class="part-item"
|
||||
:class="{ active: selectedPart?.part === part.part }"
|
||||
@click="selectedPart = part"
|
||||
@dblclick="confirmSelect(part)"
|
||||
>
|
||||
<span class="part-badge">P{{ part.part }}</span>
|
||||
<span class="part-name" :title="part.title">{{ part.title }}</span>
|
||||
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
:disabled="!selectedPart"
|
||||
@click="selectedPart && confirmSelect(selectedPart)"
|
||||
>
|
||||
{{ confirmBtnText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { usePlayerStore, type BiliPart } from '../stores/player.js';
|
||||
import CoverArt from './CoverArt.vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const modal = computed(() => store.biliPartModal);
|
||||
|
||||
const selectedPart = ref<BiliPart | null>(null);
|
||||
|
||||
// 弹窗打开时默认选中第 1 P
|
||||
watch(
|
||||
() => modal.value.open,
|
||||
(open) => {
|
||||
if (open && modal.value.parts.length > 0) {
|
||||
selectedPart.value = modal.value.parts[0];
|
||||
} else {
|
||||
selectedPart.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const actionHint = computed(() => {
|
||||
if (modal.value.action === 'playNext') return '添加到下一首播放';
|
||||
if (modal.value.action === 'add') return '添加到播放队列';
|
||||
return '立即播放';
|
||||
});
|
||||
|
||||
const confirmBtnText = computed(() => {
|
||||
if (modal.value.action === 'playNext') return '下一首播放';
|
||||
if (modal.value.action === 'add') return '添加到队列';
|
||||
return '播放';
|
||||
});
|
||||
|
||||
function confirmSelect(part: BiliPart) {
|
||||
store.selectBilibiliPart(part);
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '--:--';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.edit-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 28px;
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
opacity: 0.7;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.video-info-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
.video-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.video-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.parts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.part-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--hover-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
|
||||
|
||||
.part-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.part-name {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.part-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.part-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.part-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<!-- The signed-in user's own NetEase account, used for THEIR 私人FM instead
|
||||
of the bot's shared login (#164). -->
|
||||
<div class="account-card">
|
||||
<div class="account-header">
|
||||
<Icon icon="mdi:radio" class="account-icon" />
|
||||
<div class="account-info">
|
||||
<div class="account-name">我的网易云账号(私人FM)</div>
|
||||
<div class="account-status" :class="{ logged: status.loggedIn }">
|
||||
<template v-if="status.loggedIn">已绑定: {{ status.nickname }}</template>
|
||||
<template v-else-if="status.linked">已绑定,但登录已失效,请重新扫码</template>
|
||||
<template v-else>未绑定 — 私人FM使用机器人的共享账号</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">
|
||||
绑定后,你在网页端开启的网易云私人FM会按你自己的口味推荐;其他人不受影响。
|
||||
仅保存在服务器上,不会显示给任何人。
|
||||
</p>
|
||||
|
||||
<div class="login-methods">
|
||||
<button class="login-btn" :disabled="qr.loading" @click="startQrLogin">
|
||||
<Icon icon="mdi:qrcode" />
|
||||
{{ status.linked ? '重新扫码绑定' : '扫码绑定' }}
|
||||
</button>
|
||||
<button v-if="status.linked" class="login-btn" @click="unlink">
|
||||
<Icon icon="mdi:link-off" />
|
||||
解除绑定
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="qr.loading" class="qr-loading">
|
||||
<Icon icon="mdi:loading" class="spin" />
|
||||
生成二维码中...
|
||||
</div>
|
||||
<div v-else-if="qr.dataUrl" class="qr-wrap">
|
||||
<img :src="qr.dataUrl" class="qr-image" alt="QR Code" />
|
||||
<div class="qr-status" :class="qr.status">
|
||||
<template v-if="qr.status === 'waiting'">
|
||||
<Icon icon="mdi:cellphone" /> 请使用网易云音乐APP扫码
|
||||
</template>
|
||||
<template v-else-if="qr.status === 'scanned'">
|
||||
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
|
||||
</template>
|
||||
<template v-else-if="qr.status === 'confirmed'">
|
||||
<Icon icon="mdi:check-circle" /> 绑定成功!
|
||||
</template>
|
||||
<template v-else-if="qr.status === 'expired'">
|
||||
<Icon icon="mdi:refresh" /> 二维码已过期
|
||||
<button class="btn-link" @click="startQrLogin">重新生成</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const BASE = '/api/me/music/netease';
|
||||
|
||||
const status = reactive({ linked: false, loggedIn: false, nickname: '' });
|
||||
const qr = reactive({
|
||||
loading: false,
|
||||
dataUrl: '',
|
||||
key: '',
|
||||
status: 'waiting' as 'waiting' | 'scanned' | 'confirmed' | 'expired',
|
||||
});
|
||||
const error = ref('');
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const res = await axios.get(`${BASE}/status`);
|
||||
status.linked = Boolean(res.data?.linked);
|
||||
status.loggedIn = Boolean(res.data?.loggedIn);
|
||||
status.nickname = res.data?.nickname ?? '';
|
||||
} catch {
|
||||
// Leave the last known state
|
||||
}
|
||||
}
|
||||
|
||||
async function startQrLogin() {
|
||||
stopPolling();
|
||||
error.value = '';
|
||||
qr.loading = true;
|
||||
qr.dataUrl = '';
|
||||
qr.status = 'waiting';
|
||||
try {
|
||||
const res = await axios.post(`${BASE}/qrcode`);
|
||||
const { qrUrl, qrImg, key } = res.data;
|
||||
qr.key = key;
|
||||
// Dark-on-light only: many in-app scanners can't read an inverted code.
|
||||
qr.dataUrl = qrImg || (await QRCode.toDataURL(qrUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
}));
|
||||
pollTimer = setInterval(pollQrStatus, 2000);
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.error ?? '二维码生成失败';
|
||||
} finally {
|
||||
qr.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollQrStatus() {
|
||||
if (!qr.key) return;
|
||||
try {
|
||||
const res = await axios.get(`${BASE}/qrcode/status`, { params: { key: qr.key } });
|
||||
qr.status = res.data.status;
|
||||
if (qr.status === 'confirmed') {
|
||||
stopPolling();
|
||||
await refreshStatus();
|
||||
} else if (qr.status === 'expired') {
|
||||
stopPolling();
|
||||
}
|
||||
} catch {
|
||||
// Ignore poll errors
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
error.value = '';
|
||||
try {
|
||||
await axios.delete(BASE);
|
||||
stopPolling();
|
||||
qr.dataUrl = '';
|
||||
await refreshStatus();
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.error ?? '解除绑定失败';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshStatus);
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.account-card {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
background: var(--hover-bg);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.account-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.account-icon {
|
||||
font-size: 28px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.account-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-status {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
&.logged { color: var(--color-online); }
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-methods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
&:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.qr-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
&.confirmed { color: var(--color-online); }
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #e26a6a;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
+107
-3
@@ -17,6 +17,24 @@ export interface Song {
|
||||
|
||||
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
|
||||
|
||||
export interface BiliPart {
|
||||
part: number;
|
||||
cid: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface BiliPartModalState {
|
||||
open: boolean;
|
||||
song: Song | null;
|
||||
action: 'play' | 'playNext' | 'add';
|
||||
bvid: string;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
artist: string;
|
||||
parts: BiliPart[];
|
||||
}
|
||||
|
||||
export interface AlbumItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
|
||||
// Transient notification for surfacing failures (e.g., "song not playable")
|
||||
// to a global Toast. Bumped `id` triggers re-render of the same message.
|
||||
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
|
||||
|
||||
// Bilibili 多P分P选择弹窗状态
|
||||
biliPartModal: {
|
||||
open: false,
|
||||
song: null,
|
||||
action: 'play',
|
||||
bvid: '',
|
||||
title: '',
|
||||
coverUrl: '',
|
||||
artist: '',
|
||||
parts: [] as BiliPart[],
|
||||
} as BiliPartModalState,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
|
||||
this.notification = { id: Date.now(), message, type };
|
||||
},
|
||||
|
||||
async playSong(song: Song) {
|
||||
/**
|
||||
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
|
||||
*/
|
||||
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
|
||||
try {
|
||||
const cleanBvid = song.id.split('?')[0].split(':')[0];
|
||||
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
|
||||
const parts: BiliPart[] = res.data?.parts ?? [];
|
||||
if (parts.length > 1) {
|
||||
this.biliPartModal = {
|
||||
open: true,
|
||||
song,
|
||||
action,
|
||||
bvid: cleanBvid,
|
||||
title: res.data.title || song.name,
|
||||
coverUrl: res.data.coverUrl || song.coverUrl,
|
||||
artist: res.data.artist || song.artist,
|
||||
parts,
|
||||
};
|
||||
return true; // 弹窗接管
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
song.duration = parts[0].duration;
|
||||
}
|
||||
} catch {
|
||||
// 网络请求异常则降级为正常播放
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
selectBilibiliPart(part: BiliPart) {
|
||||
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
|
||||
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
|
||||
this.biliPartModal.open = false;
|
||||
|
||||
const partTitle = part.title && part.title !== title
|
||||
? `${title} - P${part.part} ${part.title}`
|
||||
: `${title} (P${part.part})`;
|
||||
|
||||
const partSong: Song = {
|
||||
...song,
|
||||
id: `${bvid}?p=${part.part}`,
|
||||
name: partTitle,
|
||||
artist: artist || song.artist,
|
||||
coverUrl: coverUrl || song.coverUrl,
|
||||
duration: part.duration,
|
||||
};
|
||||
|
||||
if (action === 'play') {
|
||||
this.playSong(partSong, true);
|
||||
} else if (action === 'playNext') {
|
||||
this.playNextSong(partSong, true);
|
||||
} else if (action === 'add') {
|
||||
this.addSong(partSong, true);
|
||||
}
|
||||
},
|
||||
|
||||
closeBilibiliPartModal() {
|
||||
this.biliPartModal.open = false;
|
||||
this.biliPartModal.song = null;
|
||||
},
|
||||
|
||||
async playSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'play');
|
||||
if (handled) return;
|
||||
}
|
||||
// Guests use the non-destructive "play now" (insert-next + skip) so they
|
||||
// can't wipe everyone else's queue; members/admins keep the normal behavior.
|
||||
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
|
||||
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
this._syncAfterAction();
|
||||
},
|
||||
|
||||
async playNextSong(song: Song) {
|
||||
async playNextSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
|
||||
if (handled) return;
|
||||
}
|
||||
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
|
||||
if (res.data?.message) {
|
||||
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
|
||||
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
|
||||
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
|
||||
},
|
||||
|
||||
async addSong(song: Song) {
|
||||
async addSong(song: Song, skipPartCheck = false) {
|
||||
if (!this.activeBotId) return;
|
||||
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
|
||||
const handled = await this.checkBilibiliMultiPart(song, 'add');
|
||||
if (handled) return;
|
||||
}
|
||||
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
|
||||
},
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
</form>
|
||||
<p v-if="ownPwError" class="user-error">{{ ownPwError }}</p>
|
||||
<p v-if="ownPwSuccess" class="user-success">{{ ownPwSuccess }}</p>
|
||||
<PersonalNeteaseAccount v-if="providerOn('netease') && !session.isGuest.value" />
|
||||
</section>
|
||||
|
||||
<!-- Bot Management (create/edit/delete/start-stop) requires bot.manage -->
|
||||
@@ -1215,6 +1216,7 @@ import { Icon } from '@iconify/vue';
|
||||
import axios from 'axios';
|
||||
import AvatarUpload from '../components/AvatarUpload.vue';
|
||||
import CustomAvatarRow from '../components/CustomAvatarRow.vue';
|
||||
import PersonalNeteaseAccount from '../components/PersonalNeteaseAccount.vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { usePlayerStore } from '../stores/player.js';
|
||||
import { useSession } from '../composables/useSession.js';
|
||||
|
||||
Reference in new issue
Block a user