Files
pmomusic/pmoapp/webapp/src/views/UnifiedControlView.vue
Eric Coissac ac9cb3ef3f 🔧 Frontend review fixes & quality improvements
- Fixed race condition in apiCache TTL handling by passing ttl per-entry
- Removed double snapshot reassignment bug after switch cases in useRenderers.ts  
- Replaced `as any` cast on transport_state with runtime guard (isTransportState)
- Fixed invalidate() to preserve subscriptions instead of deleting them
- Migrated reactive(Map) → shallowRef + explicit reactivity triggers (triggerSnapshotReactive, triggerLoadingReactice)
- Added onUnmounted cleanup for debounce timer in useRenderer()
- Exposed resetSSE() to allow reinitialization after SSE reconnect
- Split deep watch in useTabs.ts into separate lightweight watches without {deep: true}
- Fixed swipe gesture to capture startX in onSwipeStart instead of using final clientX
- Added minimal JSON validation log for null responses (DEV mode only)
- Implemented ARIA labels on transport controls and volume slider
- Added UI notifications for network errors via uiStore.notifyError()
+ Removed obsolete toRaw() usage and import after shallowRef migration
- Added missing reactivity triggers for state_changed, queue_refreshing/updated cases
2026-04-06 08:51:12 +02:00

386 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);
}
.content-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
position: relative;
}
/* 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>