- 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
388 lines
11 KiB
Vue
388 lines
11 KiB
Vue
<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>
|