Version 2.0.0 - update of everything

This commit is contained in:
raphix committed 2026-10-07 14:05:02 +02:00
1 parent e006d48c0a
commit 6d81a8c1c0
57 files changed
+9407 -364

No files matched your search

+410
View File
@@ -0,0 +1,410 @@
<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>