411 lines
13 KiB
Vue
411 lines
13 KiB
Vue
<template>
|
|
<Box level="second" padding="closed" no-shadow class="player-card">
|
|
<div class="card-header">
|
|
<div class="guild">
|
|
<ServerAvatar class="guild-avatar" :src="player.guildIcon" :server-id="player.guildId"/>
|
|
<div class="guild-info">
|
|
<p class="guild-name">{{ player.guildName || "Serveur inconnu" }}</p>
|
|
<p class="guild-id">{{ player.guildId }}</p>
|
|
</div>
|
|
</div>
|
|
<Tag :color="status.color" :icon="status.icon">{{ status.label }}</Tag>
|
|
</div>
|
|
|
|
<div class="current">
|
|
<img v-if="player.current?.thumbnail" class="thumbnail" :src="player.current.thumbnail" alt="Miniature"/>
|
|
<div v-else class="thumbnail placeholder"><Icon icon="fa-solid fa-music"/></div>
|
|
<div class="current-info">
|
|
<template v-if="player.current">
|
|
<a v-if="player.current.url" class="title no-decoration" :href="player.current.url" target="_blank" rel="noopener">{{ player.current.title }}</a>
|
|
<p v-else class="title">{{ player.current.title }}</p>
|
|
<p class="author">{{ player.current.author || "Auteur inconnu" }}</p>
|
|
</template>
|
|
<template v-else>
|
|
<p class="title secondary">Aucun titre en cours</p>
|
|
<p class="author">La scène est vide</p>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="player.current" class="progress">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
|
</div>
|
|
<div class="times">
|
|
<span>{{ formatClock(positionMs) }}</span>
|
|
<span v-if="isLive"><Tag color="var(--text-error)">LIVE</Tag></span>
|
|
<span v-else>{{ formatClock(durationMs) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tags">
|
|
<Tag :color="player.connection?.channelId || player.channelId ? 'var(--text-success)' : 'var(--text-error)'" icon="fa-solid fa-volume-high">
|
|
{{ player.connection?.channelName || player.channelName || "Aucun salon" }}
|
|
</Tag>
|
|
<Tag color="var(--text-secondary)" icon="fa-solid fa-headphones" title="Auditeurs dans le salon">{{ safeNum(player.listeners) }} auditeur(s)</Tag>
|
|
<Tag color="var(--text-secondary)" icon="fa-solid fa-globe" title="Utilisateurs sur l'interface web">{{ safeNum(player.webListeners) }} web</Tag>
|
|
<Tag v-if="route" :color="route === 'warp' ? 'var(--owner-color)' : 'var(--admin-color)'" icon="fa-solid fa-route"
|
|
:title="player.ytdlp?.pid ? 'yt-dlp PID ' + player.ytdlp.pid : 'Route yt-dlp'">
|
|
yt-dlp : {{ route === 'warp' ? "WARP" : "Direct" }}<span v-if="player.ytdlp?.pid" class="mono"> · {{ player.ytdlp.pid }}</span>
|
|
</Tag>
|
|
<Tag v-if="player.loop" color="var(--main)" icon="fa-solid fa-repeat">Boucle</Tag>
|
|
<Tag v-if="player.shuffle" color="var(--main)" icon="fa-solid fa-shuffle">Aléatoire</Tag>
|
|
<Tag v-if="player.pendingPause" color="var(--text-warning)" icon="fa-solid fa-hourglass-half">Pause en attente</Tag>
|
|
<Tag v-if="player.autoLeaveAt" color="var(--text-warning)" icon="fa-solid fa-door-open">Départ {{ formatRelative(player.autoLeaveAt) }}</Tag>
|
|
</div>
|
|
|
|
<p v-if="player.status === 'loading' && player.loading" class="loading-info">
|
|
<Icon icon="fa-solid fa-spinner" spin-pulse/> Chargement (essai {{ safeNum(player.loading.attempt, 1) }}<span v-if="player.loading.route">, route {{ player.loading.route }}</span>)
|
|
</p>
|
|
<Error v-if="player.lastError && !player.lastErrorStale" class="last-error">
|
|
{{ player.lastError.message || "Erreur inconnue" }} ({{ player.lastError.code }}) · {{ formatRelative(player.lastError.at) }}
|
|
</Error>
|
|
<p v-else-if="player.lastError" class="last-error stale" title="La lecture a repris depuis sur une autre musique">
|
|
<Icon icon="fa-solid fa-clock-rotate-left"/> Erreur précédente : {{ player.lastError.message || player.lastError.code }} · {{ formatRelative(player.lastError.at) }}
|
|
</p>
|
|
|
|
<div :class="['actions', { busy }]">
|
|
<IconAction title="Précédent" icon="fa-solid fa-backward-step" @click="act('previous')"/>
|
|
<IconAction v-if="player.status === 'playing' || player.status === 'loading'" title="Pause" icon="fa-solid fa-pause" @click="act('pause')"/>
|
|
<IconAction v-else title="Reprendre" icon="fa-solid fa-play" @click="act('resume')"/>
|
|
<IconAction title="Suivant" icon="fa-solid fa-forward-step" @click="act('skip')"/>
|
|
<IconAction title="Arrêter" icon="fa-solid fa-stop" @click="act('stop')"/>
|
|
<IconAction title="Vider la file d'attente" icon="fa-solid fa-trash-can" @click="act('clear')"/>
|
|
<IconAction title="Boucle" icon="fa-solid fa-repeat" :active="player.loop" :color="player.loop ? 'var(--main)' : 'var(--text)'" @click="act('loop', !player.loop)"/>
|
|
<IconAction title="Aléatoire" icon="fa-solid fa-shuffle" :active="player.shuffle" :color="player.shuffle ? 'var(--main)' : 'var(--text)'" @click="act('shuffle', !player.shuffle)"/>
|
|
<IconAction title="Quitter le salon" icon="fa-solid fa-right-from-bracket" color="var(--text-error)" @click="emit('leave', player)"/>
|
|
</div>
|
|
|
|
<div class="queue">
|
|
<button class="queue-toggle" @click="showQueue = !showQueue">
|
|
<Icon :icon="showQueue ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/>
|
|
File d'attente : {{ nextCount }} titre(s)
|
|
</button>
|
|
<div v-if="showQueue" class="queue-list">
|
|
<Info v-if="!player.next?.length" secondary>La file d'attente est vide</Info>
|
|
<div v-for="(song, index) in player.next" :key="(song.id || '') + index" class="queue-item">
|
|
<span class="queue-index">{{ index + 1 }}</span>
|
|
<div class="queue-info">
|
|
<p class="queue-title">{{ song.title }}</p>
|
|
<p class="queue-author">{{ song.author }}</p>
|
|
</div>
|
|
<span class="mono queue-duration">{{ song.duration ? formatClock(song.duration * 1000) : "LIVE" }}</span>
|
|
</div>
|
|
<p v-if="nextCount > (player.next?.length || 0)" class="queue-more">… et {{ nextCount - player.next.length }} autre(s)</p>
|
|
</div>
|
|
</div>
|
|
</Box>
|
|
</template>
|
|
<script setup>
|
|
import { computed, ref } from 'vue';
|
|
import { useAdminStore } from '@/stores/adminStore';
|
|
import Box from '@/components/UI/Box.vue';
|
|
import Tag from '@/components/UI/Tag.vue';
|
|
import Info from '@/components/UI/Info.vue';
|
|
import Error from '@/components/UI/Error.vue';
|
|
import IconAction from '@/components/UI/IconAction.vue';
|
|
import ServerAvatar from '@/components/UI/ServerAvatar.vue';
|
|
import { safeNum, formatClock, formatRelative, playerStatus } from './adminFormat';
|
|
|
|
const props = defineProps({
|
|
player: {
|
|
type: Object,
|
|
required: true
|
|
},
|
|
// performance.now() partagé, rafraîchi par AdminPlayers
|
|
now: {
|
|
type: Number,
|
|
required: true
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['leave']);
|
|
|
|
const adminStore = useAdminStore();
|
|
const busy = ref(false);
|
|
const showQueue = ref(false);
|
|
|
|
const status = computed(() => playerStatus(props.player.status || (props.player.playing ? 'playing' : props.player.paused ? 'paused' : 'idle')));
|
|
|
|
const durationMs = computed(() => {
|
|
if (props.player.durationMs !== undefined && props.player.durationMs !== null) return safeNum(props.player.durationMs);
|
|
const seconds = safeNum(props.player.current?.duration);
|
|
return seconds > 0 ? seconds * 1000 : null;
|
|
});
|
|
|
|
const isLive = computed(() => !durationMs.value);
|
|
|
|
// Position extrapolée : ancre {ms, rate, t0} posée à la réception, bornée à la durée
|
|
const positionMs = computed(() => {
|
|
const anchor = props.player.anchor;
|
|
if (!anchor) return 0;
|
|
const value = anchor.ms + anchor.rate * (props.now - anchor.t0);
|
|
return Math.max(0, Math.min(durationMs.value ?? Infinity, value));
|
|
});
|
|
|
|
const progress = computed(() => {
|
|
if (!durationMs.value) return 0;
|
|
return Math.min(100, positionMs.value / durationMs.value * 100);
|
|
});
|
|
|
|
const route = computed(() => props.player.ytdlp?.route || null);
|
|
const nextCount = computed(() => safeNum(props.player.nextCount, props.player.next?.length || 0));
|
|
|
|
// value : état voulu pour loop / shuffle (envoyé explicitement, sans bascule côté serveur)
|
|
async function act(action, value) {
|
|
if (busy.value) return;
|
|
busy.value = true;
|
|
await adminStore.playerAction(props.player.guildId, action, value);
|
|
busy.value = false;
|
|
}
|
|
</script>
|
|
<style scoped>
|
|
.player-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
|
|
.guild {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.guild-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.guild-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.guild-name {
|
|
font-weight: 600;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.guild-id {
|
|
font-size: 0.7em;
|
|
color: var(--text-secondary);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.current {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background-color: var(--secondary);
|
|
border-radius: 10px;
|
|
padding: 5px;
|
|
}
|
|
|
|
.thumbnail {
|
|
width: 30%;
|
|
min-width: 90px;
|
|
aspect-ratio: 16/9;
|
|
object-fit: cover;
|
|
border-radius: 10px;
|
|
background-color: var(--quaternary);
|
|
}
|
|
|
|
.placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.current-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
min-width: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.title {
|
|
font-weight: bold;
|
|
color: var(--text);
|
|
overflow: hidden;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.title.secondary {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.author {
|
|
color: var(--text-secondary);
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.progress {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 6px;
|
|
background-color: var(--secondary);
|
|
border-radius: 5px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background-color: var(--main);
|
|
border-radius: 5px;
|
|
transition: width 0.5s linear;
|
|
}
|
|
|
|
.times {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-family: monospace;
|
|
font-size: 0.8em;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.tags {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 5px;
|
|
}
|
|
|
|
.mono {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.loading-info {
|
|
font-size: 0.8em;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.last-error {
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.last-error.stale {
|
|
margin: 0;
|
|
color: var(--text-secondary);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
background-color: var(--secondary);
|
|
border-radius: 10px;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.actions.busy {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.queue-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
font-size: 0.85em;
|
|
padding: 0;
|
|
}
|
|
|
|
.queue-toggle:hover {
|
|
color: var(--text);
|
|
}
|
|
|
|
.queue-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
margin-top: 5px;
|
|
max-height: 25vh;
|
|
overflow-y: auto;
|
|
animation: unfold 0.3s ease-in-out;
|
|
}
|
|
|
|
.queue-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
background-color: var(--secondary);
|
|
border-radius: 5px;
|
|
padding: 5px;
|
|
font-size: 0.8em;
|
|
}
|
|
|
|
.queue-index {
|
|
color: var(--text-secondary);
|
|
font-family: monospace;
|
|
width: 20px;
|
|
text-align: center;
|
|
}
|
|
|
|
.queue-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.queue-title {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.queue-author {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.queue-duration {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.queue-more {
|
|
font-size: 0.8em;
|
|
color: var(--text-secondary);
|
|
text-align: center;
|
|
}
|
|
</style>
|