[FE] Révise la stabilité, réactivité et performance du frontend

- Corrige fuites mémoire SSE via nettoyage des listeners (P0)
- Remplace shallowRef<Map> par reactive(new Map()) pour réactivité native Vue (P1)
- Ajoute timeout/AbortController aux requêtes fetch dans l'API client (P4)
- Déboucle les watch() de useTabs avec debounce unique et try/finally sur isRestoringFromStorage (P7)
- Limite notifications à 5 + nettoyage des timers dans UI store (P15)
- Protège routes debug avec import dynamique uniquement en DEV + wildcard 403 (P8)
- Déplace SVG par défaut dans assets/default-cover.svg et import ?raw (P10)
- Ajoute @media prefers-reduced-motion aux animations CSS globales
- Factorise styles drawer-btns avec .drawer-icon-classe + ajoute --opacity-disabled (P13)
- Encode les clés de cache browse avec encodeURIComponent + ':' séparateur (P14)
- Implémente pagination infinite scroll dans browseContainer/loadMore
+ Supprime formatMsToShortTime alias (P9)
- Corrige truncate() pour éviter dépassement maxLength si suffix >=maxLength (P10)
- Valide structure des commandes PMOPlayer avant traitement
+ Met à jour version Cargo.toml et lock (0.3.39)
This commit is contained in:
2026-04-06 09:50:54 +02:00
parent 78c37e1732
commit 8be250b167
26 changed files with 881 additions and 290 deletions

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -101,13 +101,15 @@
font-size: var(--text-base);
}
/* Bouton fermer */
.drawer-close-btn {
/* ========================================
ICONS COMMUNS POUR DRAWERS
======================================== */
/* Classe de base pour les boutons icônes des drawers */
.drawer-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
padding: 0;
background: rgba(255, 255, 255, 0.1);
@@ -118,34 +120,31 @@
color: var(--color-text);
}
.drawer-close-btn:hover {
.drawer-icon-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.drawer-close-btn:active {
.drawer-icon-btn:active {
transform: scale(0.95);
}
/* Bouton fermer */
.drawer-close-btn {
width: 40px;
height: 40px;
}
.drawer-close-btn:hover {
transform: scale(1.1);
}
/* Bouton retour (ServerDrawer navigation) */
.drawer-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-fast) ease;
color: var(--color-text);
}
.drawer-back-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}

View File

@@ -381,24 +381,27 @@
ANIMATIONS
======================================== */
@keyframes glassShimmer {
0% {
background-position: -200% center;
/* Respecte prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
@keyframes glassShimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
100% {
background-position: 200% center;
}
}
.glass-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: glassShimmer 2s ease-in-out infinite;
.glass-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: glassShimmer 2s ease-in-out infinite;
}
}
/* ========================================

View File

@@ -79,7 +79,14 @@ body {
background-color: var(--status-transitioning-bg);
color: var(--status-transitioning);
border: 1px solid var(--status-transitioning);
animation: pulse 2s infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.status-badge.transitioning {
animation: pulse 2s infinite;
}
}
/* ========================================
@@ -226,52 +233,81 @@ body {
/* ========================================
Animations
======================================== */
@keyframes pulse {
0%, 100% {
opacity: 1;
/* Respecte prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
50% {
opacity: 0.7;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse-opacity {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-badge.transitioning {
animation: pulse 2s infinite;
}
.shuffle-button.loading {
animation: pulse 1s infinite;
}
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.fade-in {
animation: fadeIn var(--transition-base);
}
.spin {
animation: spin 1s linear infinite;
/* Default state (animations disabled) */
.status-badge.transitioning,
.shuffle-button.loading,
.event-badge,
.loading-state {
animation: none;
}
/* Reduced motion support */
@@ -546,10 +582,49 @@ input[type="range"]::-moz-range-thumb:hover {
}
/* ========================================
ANIMATIONS
ANIMATIONS (Keyframes for no-preference media query)
======================================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--transition-base);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes pulse-opacity {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
}

View File

@@ -89,13 +89,18 @@
--transition-base: 300ms ease-in-out;
--transition-slow: 500ms ease-in-out;
/* ========================================
Z-index layers
======================================== */
/* ========================================
Z-index layers
======================================== */
--z-dropdown: 100;
--z-modal: 200;
--z-toast: 300;
--z-tooltip: 400;
/* ========================================
Opacity states
======================================== */
--opacity-disabled: 0.4;
}
/* Dark mode support (optionnel pour l'avenir) */

View File

@@ -981,7 +981,14 @@ button.active {
padding: 3rem;
color: #569cd6;
font-size: 1.1rem;
animation: pulse 1.5s ease-in-out infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {

View File

@@ -76,7 +76,14 @@ async function handleShuffle() {
}
.shuffle-button.loading {
animation: pulse 1s infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.shuffle-button.loading {
animation: pulse 1s infinite;
}
}
@keyframes pulse {

View File

@@ -372,7 +372,14 @@ watch(editingVar, (newVar) => {
.event-badge {
font-size: 1rem;
animation: pulse 2s ease-in-out infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
}
@keyframes pulse {

View File

@@ -37,9 +37,20 @@ class ImageCacheService {
};
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
constructor() {
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
}
/**
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
*/
destroy(): void {
if (this.cleanupIntervalId !== null) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
}
configure(options: Partial<ImageCacheOptions>) {

View File

@@ -20,11 +20,17 @@ export interface BrowseState {
container_id: string
entries: ContainerEntry[]
total_count: number
hasMore?: boolean
currentOffset?: number
}
// Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
function browseCacheKey(serverId: string, containerId: string): string {
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
}
const currentPath = ref<BreadcrumbItem[]>([])
const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
@@ -69,9 +75,10 @@ function ensureSSEInitialized() {
}
// Invalider tout le cache browse de ce serveur
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})
@@ -80,9 +87,10 @@ function ensureSSEInitialized() {
case 'global_updated':
// Invalider tout le cache de ce serveur
const encodedServerIdGlobal = encodeURIComponent(serverId)
const globalKeysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerIdGlobal + ':')) {
globalKeysToDelete.push(key)
}
})
@@ -92,7 +100,7 @@ function ensureSSEInitialized() {
case 'containers_updated':
// Invalider les containers spécifiques
event.container_ids.forEach(containerId => {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
browseCache.value.delete(key)
})
break
@@ -142,7 +150,7 @@ export function useMediaServers() {
// Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
@@ -152,12 +160,14 @@ export function useMediaServers() {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId, 0)
const data = await api.browseContainer(serverId, containerId, 0, 50)
browseCache.value.set(key, {
container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
hasMore: data.entries.length < data.total_count,
currentOffset: data.entries.length,
})
return browseCache.value.get(key)!
@@ -172,22 +182,26 @@ export function useMediaServers() {
// Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return
if (state.entries.length >= state.total_count) return
if (!('hasMore' in state) || !state.hasMore) return
if (loadingMore.value) return
try {
loadingMore.value = true
const offset = state.entries.length
// Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
const offset = state.currentOffset ?? state.entries.length
const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées
state.entries.push(...data.entries)
state.total_count = data.total_count
state.currentOffset = state.entries.length
state.hasMore = state.entries.length < state.total_count
// Forcer la réactivité
browseCache.value.set(key, { ...state })
} catch (e) {
@@ -236,12 +250,12 @@ export function useMediaServers() {
}
function getBrowseCached(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
return browseCache.value.get(key)
}
function hasMore(serverId: string, containerId: string): boolean {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return false
return state.entries.length < state.total_count
@@ -259,12 +273,12 @@ export function useMediaServers() {
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
browseCache.value.delete(browseCacheKey(serverId, containerId))
} else {
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})

View File

@@ -4,7 +4,7 @@
* - Les snapshots complets proviennent de /renderers/{id}/full
* - Les événements SSE ne servent qu'à déclencher un refetch.
*/
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
import { api } from "../services/pmocontrol/api";
import { useSSE } from "./useSSE";
import { apiCache } from "./apiCache";
@@ -19,31 +19,19 @@ import type {
} from "../services/pmocontrol/types";
import { isTransportState } from "../services/pmocontrol/types";
// État global des snapshots avec shallowRef pour éviter les problèmes de réactivité avec les Maps
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
const lastSnapshotAt = shallowRef(new Map<string, number>());
const lastEventAt = shallowRef(new Map<string, number>());
const loadingIds = shallowRef(new Set<string>());
const queueRefreshingIds = shallowRef(new Set<string>());
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
const lastSnapshotAt = reactive(new Map<string, number>());
const lastEventAt = reactive(new Map<string, number>());
const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(null);
// Cache des renderers (summary)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
// Helper pour déclencher la réactivité après mutation des Maps
function triggerSnapshotReactivity() {
// Créer une nouvelle référence pour déclencher la réactivité
snapshots.value = new Map(snapshots.value);
}
function triggerLoadingReactivity() {
loadingIds.value = new Set(loadingIds.value);
}
function triggerQueueReactivity() {
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
}
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
const loading = ref(false);
const error = ref<string | null>(null);
@@ -114,17 +102,16 @@ function ensureSSEInitialized() {
}
// Supprimer le snapshot (il n'est plus valide)
snapshots.value.delete(rendererId);
triggerSnapshotReactivity();
lastSnapshotAt.value.delete(rendererId);
lastEventAt.value.delete(rendererId);
snapshots.delete(rendererId);
lastSnapshotAt.delete(rendererId);
lastEventAt.delete(rendererId);
return;
}
// Pour les autres événements, mettre à jour le snapshot local directement
lastEventAt.value.set(rendererId, timestamp);
lastEventAt.set(rendererId, timestamp);
const snapshot = snapshots.value.get(rendererId);
const snapshot = snapshots.get(rendererId);
// Si pas de snapshot, on doit fetch
if (!snapshot) {
@@ -137,11 +124,11 @@ function ensureSSEInitialized() {
case "state_changed":
// Créer un nouvel objet pour déclencher la réactivité
if (isTransportState(event.state)) {
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, transport_state: event.state },
});
triggerSnapshotReactivity();
} else {
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
}
@@ -158,7 +145,7 @@ function ensureSSEInitialized() {
const durationMs = parseTimeToMs(event.track_duration ?? null);
// Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: {
...snapshot.state,
@@ -166,12 +153,12 @@ function ensureSSEInitialized() {
duration_ms: durationMs,
},
});
triggerSnapshotReactivity();
break;
case "volume_changed":
// Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, volume: event.volume },
});
@@ -179,7 +166,7 @@ function ensureSSEInitialized() {
case "mute_changed":
// Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, mute: event.mute },
});
@@ -199,21 +186,21 @@ function ensureSSEInitialized() {
snapshot.state.current_track.album = event.album;
snapshot.state.current_track.album_art_uri = event.album_art_uri;
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state },
});
break;
case "queue_refreshing":
queueRefreshingIds.value.add(rendererId);
triggerQueueReactivity();
queueRefreshingIds.add(rendererId);
break;
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.value.delete(rendererId);
triggerQueueReactivity();
queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
void fetchRendererSnapshot(rendererId, { force: true });
break;
@@ -231,7 +218,7 @@ function ensureSSEInitialized() {
snapshot.state.attached_playlist = null;
}
// Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state },
});
@@ -240,7 +227,7 @@ function ensureSSEInitialized() {
case "stream_state_changed":
snapshot.is_stream = event.is_stream;
// Créer un nouvel objet pour déclencher la réactivité
snapshots.value.set(rendererId, { ...snapshot });
snapshots.set(rendererId, { ...snapshot });
break;
case "timer_started":
@@ -265,7 +252,7 @@ const onlineRenderers = computed(() =>
allRenderers.value.filter((r) => r.online),
);
const allSnapshots = computed(() =>
Array.from(snapshots.value.values()),
Array.from(snapshots.values()),
);
const playingRenderers = computed(() =>
allSnapshots.value
@@ -278,27 +265,27 @@ function getRendererById(id: string) {
}
function getSnapshotById(id: string) {
return snapshots.value.get(id) ?? null;
return snapshots.get(id) ?? null;
}
function getStateById(id: string): RendererState | null {
return snapshots.value.get(id)?.state ?? null;
return snapshots.get(id)?.state ?? null;
}
function getQueueById(id: string): QueueSnapshot | null {
return snapshots.value.get(id)?.queue ?? null;
return snapshots.get(id)?.queue ?? null;
}
function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshots.value.get(id)?.binding ?? null;
return snapshots.get(id)?.binding ?? null;
}
function isSnapshotLoading(id: string) {
return loadingIds.value.has(id);
return loadingIds.has(id);
}
function isQueueRefreshing(id: string) {
return queueRefreshingIds.value.has(id);
return queueRefreshingIds.has(id);
}
function selectRenderer(id: string | null) {
@@ -353,43 +340,43 @@ async function fetchRendererSnapshot(
) {
ensureSSEInitialized();
const force = opts?.force ?? false;
const hasSnapshot = snapshots.value.has(rendererId);
const hasSnapshot = snapshots.has(rendererId);
if (!force && hasSnapshot) {
const lastSnapshot = lastSnapshotAt.value.get(rendererId) ?? 0;
const lastEvent = lastEventAt.value.get(rendererId) ?? 0;
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = lastEventAt.get(rendererId) ?? 0;
if (lastEvent <= lastSnapshot) {
return;
}
}
// Éviter les requêtes multiples simultanées pour le même renderer
if (loadingIds.value.has(rendererId)) {
if (loadingIds.has(rendererId)) {
return;
}
loadingIds.value.add(rendererId);
triggerLoadingReactivity();
loadingIds.add(rendererId);
// Lazy load UI store pour les notifications
const uiStore = useUIStore();
try {
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshots.value.set(rendererId, snapshot);
lastSnapshotAt.value.set(rendererId, Date.now());
triggerSnapshotReactivity();
snapshots.set(rendererId, snapshot);
lastSnapshotAt.set(rendererId, Date.now());
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
snapshots.value.delete(rendererId);
triggerSnapshotReactivity();
snapshots.delete(rendererId);
// Notifier l'utilisateur
uiStore.notifyError(`Impossible de récupérer l'état du renderer`);
} finally {
// Toujours nettoyer le flag de chargement
loadingIds.value.delete(rendererId);
triggerLoadingReactivity();
loadingIds.delete(rendererId);
}
}
@@ -439,7 +426,7 @@ async function play(id: string) {
}
async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshots.value.get(id);
const snapshot = snapshots.get(id);
if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`);
}

View File

@@ -17,13 +17,17 @@ import type {
} from '../services/pmocontrol/types'
// État global partagé
const connected = ref(sse.isConnectedState())
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
const connected = ref(sse.isConnectedState());
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
// Flag pour éviter les double-connexions SSE avec lock
let connectionLock = false;
// Abonnement à l'état de connexion global
function setupConnectionListener() {
// S'assurer qu'on ne s'abonne qu'une seule fois
if (connectionCallbacks.size === 0) {
// Vérifier avec lock pour éviter les conditions de course
if (connectionCallbacks.size === 0 && !connectionLock) {
connectionLock = true;
sse.onConnectionChange((isConnected) => {
connected.value = isConnected
connectionCallbacks.forEach(cb => cb(isConnected))

View File

@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
// Flag pour éviter les boucles de sauvegarde
let isRestoringFromStorage = false;
// Debounce timer pour la sauvegarde localStorage
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const SAVE_DEBOUNCE_MS = 100;
/**
* Retourne le titre complet sans troncature
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
}
/**
* Sauvegarde l'état dans localStorage
* Sauvegarde l'état dans localStorage (avec debounce)
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
*/
function saveToLocalStorage() {
if (isRestoringFromStorage) return;
try {
const stateToSave = {
// Sauvegarder uniquement les onglets server (fermables manuellement)
tabs: state.tabs
.filter((tab) => tab.type === "server")
.map((tab) => ({
...tab,
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
icon: undefined,
})),
activeTabId: state.activeTabId,
tabHistory: state.tabHistory,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
// Annuler le timer précédent
if (saveDebounceTimer !== null) {
clearTimeout(saveDebounceTimer);
}
// Débouncer pour éviter les écritures multiples
saveDebounceTimer = setTimeout(() => {
try {
const stateToSave = {
// Sauvegarder uniquement les onglets server (fermables manuellement)
tabs: state.tabs
.filter((tab) => tab.type === "server")
.map((tab) => ({
...tab,
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
icon: undefined,
})),
activeTabId: state.activeTabId,
tabHistory: state.tabHistory,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
}
saveDebounceTimer = null;
}, SAVE_DEBOUNCE_MS);
}
/**
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
*/
function restoreFromLocalStorage() {
isRestoringFromStorage = true;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
isRestoringFromStorage = true;
const savedState = JSON.parse(saved);
// Reconstituer uniquement les tabs server avec les bonnes icônes
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
state.activeTabId = "";
}
isRestoringFromStorage = false;
} catch (error) {
console.error("[useTabs] Erreur restauration localStorage:", error);
} finally {
isRestoringFromStorage = false;
}
}

View File

@@ -9,6 +9,9 @@ import { sse } from "./services/pmocontrol/sse";
// Store UI (garde UIStore pour les notifications et état UI global)
import { useUIStore } from "./stores/ui";
// Image cache (pour cleanup)
import { imageCache } from "./composables/imageCache";
// Styles
import "./style.css";
import "./assets/styles/variables.css";
@@ -24,12 +27,13 @@ const pinia = createPinia();
app.use(pinia);
app.use(router);
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
const uiStore = useUIStore();
// Monter l'application
app.mount("#app");
// Après montage, initialiser SSE
const uiStore = useUIStore();
// Les composables se connectent automatiquement à SSE
// Ils gèrent eux-mêmes le re-fetch lors des événements
@@ -39,3 +43,8 @@ sse.onConnectionChange((connected) => {
// Démarrer la connexion SSE
sse.connect();
// Cleanup global lors du unload de la page
window.addEventListener('beforeunload', () => {
imageCache.destroy();
});

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
// PMOControl Unified View (nouvelle interface unifiée)
import UnifiedControlView from "../views/UnifiedControlView.vue";
@@ -8,18 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
import RendererView from "../views/RendererView.vue";
import MediaServerView from "../views/MediaServerView.vue";
// Debug Components (anciennes routes)
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue";
import AudioCacheManager from "../components/AudioCacheManager.vue";
import PlayListManager from "../components/PlayListManager.vue";
import UpnpExplorer from "../components/UpnpExplorer.vue";
import APIDashboard from "../components/APIDashboard.vue";
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
import DebugView from "../views/DebugView.vue";
// Debug Components - lazy loaded uniquement en mode développement (P8)
const isDev = import.meta.env.DEV;
const routes = [
const routes: RouteRecordRaw[] = [
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
{
path: "/",
@@ -43,57 +36,65 @@ const routes = [
name: "MediaServer",
component: MediaServerView,
},
// Debug hub
{
path: "/debug",
name: "Debug",
component: DebugView,
},
// Debug menu (anciennes routes déplacées sous /debug)
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: GenericMusicPlayer,
},
{
path: "/debug/logs",
name: "Logs",
component: LogView,
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: CoverCacheManager,
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: AudioCacheManager,
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: PlayListManager,
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: UpnpExplorer,
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: APIDashboard,
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: RadioParadiseExplorer,
},
];
// Ajouter les routes de debug uniquement en développement
if (isDev) {
routes.push(
{
path: "/debug",
name: "Debug",
component: () => import("../views/DebugView.vue"),
},
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: () => import("../components/GenericMusicPlayer.vue"),
},
{
path: "/debug/logs",
name: "Logs",
component: () => import("../components/LogView.vue"),
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: () => import("../components/CoverCacheManager.vue"),
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: () => import("../components/AudioCacheManager.vue"),
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: () => import("../components/PlayListManager.vue"),
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: () => import("../components/UpnpExplorer.vue"),
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: () => import("../components/APIDashboard.vue"),
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: () => import("../components/RadioParadiseExplorer.vue"),
}
);
}
// Wildcard redirect pour les routes inconnues
routes.push({
path: "/:pathMatch(.*)*",
redirect: "/",
});
const router = createRouter({
// history avec base /app
history: createWebHistory("/app"),

View File

@@ -33,6 +33,58 @@ export interface TrackInfo {
cover?: string;
}
// Types stricts pour les commandes reçues du backend (P6)
interface StreamCommand {
type: 'stream';
url: string;
}
interface PlayCommand {
type: 'play';
}
interface PauseCommand {
type: 'pause';
}
interface SeekCommand {
type: 'seek';
timestamp: number;
}
interface FlushCommand {
type: 'flush';
}
interface StopCommand {
type: 'stop';
}
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
if (!msg || typeof msg !== 'object') return false;
if (!('type' in msg)) return false;
const type = (msg as Record<string, unknown>).type;
if (typeof type !== 'string') return false;
// Valider les champs selon le type
switch (type) {
case 'stream':
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
case 'seek':
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
case 'play':
case 'pause':
case 'flush':
case 'stop':
return true;
default:
return false;
}
}
export class PMOPlayer {
private audio: HTMLAudioElement;
private instanceId: string;
@@ -195,11 +247,17 @@ export class PMOPlayer {
}
private handleCommand(msg: Record<string, unknown>) {
const type = msg.type as string;
// Validate command structure before processing (P6)
if (!isValidCommand(msg)) {
console.warn('[PMOPlayer] Invalid command received:', msg);
return;
}
const type = msg.type;
switch (type) {
case 'stream': {
this.playStream(msg.url as string);
this.playStream(msg.url);
break;
}
case 'play': {
@@ -211,7 +269,7 @@ export class PMOPlayer {
this.pause();
break;
case 'seek':
this.seek(msg.timestamp as number);
this.seek(msg.timestamp);
break;
case 'flush':
this.flush();

View File

@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
return `/covers/jpeg/${pk}`;
}
/**
* SVG par défaut pour les images qui ne se chargent pas
*/
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>`;
import defaultCoverSvg from '../assets/default-cover.svg?raw';
/**
* Retourne l'URL de l'image par défaut comme data URL
*/
export function getDefaultImageUrl(): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
}

View File

@@ -17,12 +17,45 @@ import type {
ErrorResponse,
} from "./types";
/**
* Fetch avec timeout et AbortController
*/
function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = 10_000,
): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(
() => clearTimeout(id),
);
}
/**
* Client API REST pour le Control Point PMOMusic
*/
class PMOControlAPI {
private readonly baseURL = "/api/control";
/**
* Valide la structure de base d'une réponse
* Jette une erreur si la réponse est invalide
*/
private validateResponse<T>(data: unknown, path: string): T {
// Vérification basique : null ou undefined
if (data == null) {
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
}
// Vérification que c'est un objet
if (typeof data !== 'object') {
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
}
return data as T;
}
/**
* Effectue une requête HTTP générique
*/
@@ -32,7 +65,7 @@ class PMOControlAPI {
): Promise<T> {
const url = `${this.baseURL}${path}`;
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
...options,
headers: {
"Content-Type": "application/json",
@@ -48,10 +81,14 @@ class PMOControlAPI {
}
const data = await response.json();
// Validation de la réponse (P5)
const validated = this.validateResponse<T>(data, path);
if (import.meta.env.DEV && data == null) {
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
}
return data;
return validated;
}
// ============================================================================

View File

@@ -126,6 +126,7 @@ export interface BrowseResponse {
entries: ContainerEntry[];
total_count: number;
offset: number;
hasMore?: boolean; // Client-side flag pour infinite scroll
}
// ============================================================================

View File

@@ -9,6 +9,8 @@ export interface Notification {
duration?: number // ms, undefined = permanent
}
const MAX_NOTIFICATIONS = 5
export const useUIStore = defineStore('ui', () => {
// État
const selectedRendererId = ref<string | null>(null)
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
const sseConnected = ref(false)
const notifications = ref<Notification[]>([])
// Map pour suivre les timers et permettre le cleanup
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Actions
function selectRenderer(id: string | null) {
selectedRendererId.value = id
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
message: string,
duration?: number
) {
// Limiter le nombre de notifications (P15)
if (notifications.value.length >= MAX_NOTIFICATIONS) {
const oldest = notifications.value.shift()
if (oldest) {
const timer = notificationTimers.get(oldest.id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(oldest.id)
}
}
}
const id = `notif-${Date.now()}-${Math.random()}`
const notification: Notification = {
id,
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
notifications.value.push(notification)
// Auto-remove après duration (défaut: 5s)
// Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
const timeout = duration !== undefined ? duration : 5000
if (timeout > 0) {
setTimeout(() => {
const timer = setTimeout(() => {
removeNotification(id)
}, timeout)
notificationTimers.set(id, timer)
}
return id
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
if (index !== -1) {
notifications.value.splice(index, 1)
}
// Nettoyer le timer associated
const timer = notificationTimers.get(id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(id)
}
}
function clearNotifications() {
// Nettoyer tous les timers
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
notifications.value = []
}
// Cleanup function pour appeler lors du unmount de l'app
function $dispose() {
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
}
// Raccourcis pour les types de notifications
function notifySuccess(message: string, duration?: number) {
return addNotification('success', message, duration)
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
notifyError,
notifyWarning,
notifyInfo,
$dispose,
}
})

View File

@@ -45,5 +45,9 @@ export function normalizeUrl(url: string): string {
*/
export function truncate(str: string, maxLength: number, suffix = '...'): string {
if (str.length <= maxLength) return str;
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
if (suffix.length >= maxLength) {
return str.slice(0, maxLength);
}
return str.slice(0, maxLength - suffix.length) + suffix;
}

View File

@@ -49,12 +49,3 @@ export function formatMsToTime(ms: number | null): string {
return `${h}${m}${s}`;
}
/**
* Convertit des millisecondes en format court (pour l'affichage progress)
* @param ms - Durée en millisecondes
* @returns Durée au format "X:XX" ou "X:XX:XX"
*/
export function formatMsToShortTime(ms: number | null): string {
return formatMsToTime(ms);
}