[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

@@ -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;
}
}