Files
pmomusic/pmoapp/webapp/src/views/UnifiedControlView.vue
Eric Coissac abe8c1a4b2 Optimise frontend performance for large playlists (~1000 tracks)
- Virtualize queue list in QueueViewer.vue using RecycleScroller
- Add 300ms debounce on queue_updated refetches to prevent cascading JSON fetch
- Implement client-side pagination (100 items/page) + cache in PlayListManager.vue with sortedTracks memoization
- Limit browse infinite scroll memory usage via sliding window (200 items)
+ Minor CSS fixes for flexbox overflow/min-width to support virtualization
2026-04-06 17:48:41 +02:00

388 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch, onMounted, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTabs } from "@/composables/useTabs";
import { useRenderers } from "@/composables/useRenderers";
import { useMediaServers } from "@/composables/useMediaServers";
import { useWebRenderer } from "@/composables/useWebRenderer";
import { useSwipe } from "@vueuse/core";
// Import des composants
import BottomTabBar from "@/components/unified/BottomTabBar.vue";
import EmptyState from "@/components/unified/EmptyState.vue";
import ServerDrawer from "@/components/unified/ServerDrawer.vue";
import RendererDrawer from "@/components/unified/RendererDrawer.vue";
import RendererTabContent from "@/components/unified/RendererTabContent.vue";
import ServerTabContent from "@/components/unified/ServerTabContent.vue";
const route = useRoute();
const router = useRouter();
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
useTabs();
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const { allServers, fetchServers } = useMediaServers();
// WebRenderer : ce navigateur s'enregistre automatiquement comme renderer UPnP
const webRenderer = useWebRenderer();
// Rafraîchir la liste des renderers dès que la session WebRenderer est établie
webRenderer.onConnected(() => void fetchRenderers(true));
// État des drawers
const drawerOpen = ref(false);
const rendererDrawerOpen = ref(false);
// Ref pour le swipe edge detection
const viewRef = ref<HTMLElement | null>(null);
// Position initiale du swipe pour détecter un swipe depuis le bord gauche
const swipeStartX = ref(0);
// Swipe depuis le bord gauche pour ouvrir le drawer
useSwipe(viewRef, {
threshold: 50,
onSwipeStart(e: TouchEvent) {
swipeStartX.value = e.touches[0]?.clientX ?? 0;
},
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
// Swipe right depuis le bord gauche → ouvrir drawer
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
drawerOpen.value = true;
}
},
});
// Récupérer l'ID du renderer actuellement sélectionné
const currentRendererId = computed(() => {
const currentTab = activeTab.value;
if (currentTab?.type === "renderer" && currentTab.metadata?.rendererId) {
return currentTab.metadata.rendererId;
}
return null;
});
// Récupérer le renderer actif
const activeRenderer = computed(() => {
const rendererId = currentRendererId.value;
if (!rendererId) return null;
return allRenderers.value.find((r) => r.id === rendererId) || null;
});
// Récupérer l'état du renderer actif
const activeRendererState = computed(() => {
const rendererId = currentRendererId.value;
if (!rendererId) return null;
return getStateById(rendererId);
});
// Nombre de servers online pour afficher dans le badge
const onlineServersCount = computed(
() => allServers.value.filter((s) => s.online).length,
);
// Nombre de renderers online (filtrés) pour afficher dans le badge
const onlineRenderersCount = computed(
() => filterRenderers(allRenderers.value).filter((r) => r.online).length,
);
// Gestion de l'ouverture du drawer depuis le bouton
function handleDrawerOpen() {
drawerOpen.value = true;
}
// Gestion de l'ouverture du renderer drawer depuis le bouton
function handleRendererDrawerOpen() {
rendererDrawerOpen.value = true;
}
// Gestion de la sélection d'un renderer depuis le drawer
function handleRendererSelect(rendererId: string) {
// Chercher le tab correspondant à ce renderer ou le créer
const existingTab = tabs.value.find(
(t) => t.type === "renderer" && t.metadata?.rendererId === rendererId,
);
if (existingTab) {
switchTab(existingTab.id);
}
}
// Filter to show only our own WebRenderer based on UDN match
function filterRenderers(renderers: typeof allRenderers.value) {
const myUdn = webRenderer.rendererUdn.value;
const normalizedUdn = myUdn?.replace(/^uuid:/, '') ?? null;
return renderers.filter((r) => {
// WebRenderers have "Web Audio " as friendly_name prefix
const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
});
}
// Sync route query params avec l'état des tabs
onMounted(async () => {
// Fetch renderers et servers au montage
await Promise.all([fetchRenderers(), fetchServers()]);
// Sync initial des tabs avec les renderers (en excluant les WebRenderers étrangers)
syncWithRenderers(filterRenderers(allRenderers.value));
// Restaurer l'onglet actif depuis l'URL
const urlTabId = route.query.tab as string;
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
switchTab(urlTabId);
}
});
// Watch renderers pour sync automatique des tabs
// Note: on watch la taille du tableau + les IDs pour éviter un deep watch coûteux
watch(
() => ({
length: allRenderers.value.length,
ids: allRenderers.value.map(r => r.id).join(','),
}),
(newVal, oldVal) => {
// Re-sync uniquement si le nombre ou les IDs ont changé
if (newVal.length !== oldVal?.length || newVal.ids !== oldVal?.ids) {
syncWithRenderers(filterRenderers(allRenderers.value));
}
},
);
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
watch(
() => webRenderer.rendererUdn.value,
() => {
syncWithRenderers(filterRenderers(allRenderers.value));
},
);
// Watch les changements d'URL pour changer d'onglet
watch(
() => route.query.tab,
(newTabId) => {
if (newTabId && typeof newTabId === "string") {
const tab = tabs.value.find((t) => t.id === newTabId);
if (tab && activeTabId.value !== newTabId) {
switchTab(newTabId);
}
}
},
);
// Watch les changements d'onglet actif pour mettre à jour l'URL
watch(
() => activeTabId.value,
(newActiveTabId) => {
const currentTabId = route.query.tab as string;
if (currentTabId !== newActiveTabId) {
router.replace({
query: {
...route.query,
tab: newActiveTabId,
tabs: tabs.value.map((t) => t.id).join(","),
},
});
}
},
);
// Composant dynamique selon le type d'onglet
const currentTabComponent = computed(() => {
const tab = activeTab.value;
if (!tab) return null;
switch (tab.type) {
case "renderer":
return RendererTabContent;
case "server":
return ServerTabContent;
default:
return null;
}
});
// Props pour le composant actif
const currentTabProps = computed(() => {
const tab = activeTab.value;
if (!tab || !tab.metadata) return null;
if (tab.type === "renderer" && tab.metadata.rendererId) {
return { rendererId: tab.metadata.rendererId };
}
if (tab.type === "server" && tab.metadata.serverId) {
return { serverId: tab.metadata.serverId };
}
return null;
});
</script>
<template>
<div ref="viewRef" class="unified-control-view">
<!-- Zone de contenu -->
<main class="content-area">
<!-- État vide: aucun renderer détecté -->
<EmptyState v-if="isEmpty" />
<!-- Onglets renderers/servers avec keep-alive -->
<keep-alive v-else :max="12">
<component
v-if="currentTabComponent && currentTabProps"
:is="currentTabComponent"
:key="activeTab?.id"
v-bind="currentTabProps"
/>
</keep-alive>
</main>
<!-- Barre d'infos en bas -->
<BottomTabBar
:online-servers-count="onlineServersCount"
:online-renderers-count="onlineRenderersCount"
:active-renderer="activeRenderer"
:active-renderer-state="activeRendererState"
@open-drawer="handleDrawerOpen"
@open-renderer-drawer="handleRendererDrawerOpen"
/>
<!-- Drawer servers (swipe depuis bord gauche) -->
<ServerDrawer
v-model="drawerOpen"
:selected-renderer-id="currentRendererId"
/>
<!-- Drawer renderers (depuis bord droit) -->
<RendererDrawer
v-model="rendererDrawerOpen"
:selected-renderer-id="currentRendererId"
@select-renderer="handleRendererSelect"
/>
</div>
</template>
<style scoped>
.unified-control-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
background: var(--color-bg);
min-width: 0;
}
.content-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
position: relative;
min-width: 0;
}
/* Placeholder temporaire */
.tab-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: var(--spacing-xl);
}
.placeholder-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
max-width: 500px;
padding: var(--spacing-xl);
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
text-align: center;
}
.placeholder-icon {
color: var(--color-primary);
opacity: 0.5;
}
.placeholder-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.placeholder-text {
font-size: var(--text-base);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.6;
}
.placeholder-subtitle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-style: italic;
margin: 0;
}
/* Responsive pour 800x600 landscape */
@media (min-width: 600px) and (orientation: landscape) {
.content-area {
padding: var(--spacing-md);
}
}
/* Responsive mobile portrait */
@media (max-width: 768px) and (orientation: portrait) {
.content-area {
padding: var(--spacing-sm);
}
.placeholder-card {
padding: var(--spacing-lg);
}
.placeholder-icon {
width: 48px;
height: 48px;
}
.placeholder-title {
font-size: var(--text-xl);
}
.placeholder-text {
font-size: var(--text-sm);
}
}
/* Animation de transition des onglets */
.v-enter-active,
.v-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.v-enter-from {
opacity: 0;
transform: translateX(20px);
}
.v-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>