diff --git a/pmoapp/webapp/package-lock.json b/pmoapp/webapp/package-lock.json index a36a612c..56cdbd80 100644 --- a/pmoapp/webapp/package-lock.json +++ b/pmoapp/webapp/package-lock.json @@ -8,6 +8,7 @@ "name": "webapp", "version": "0.0.0", "dependencies": { + "@vueuse/core": "^14.1.0", "dompurify": "^3.2.7", "lucide-vue-next": "^0.555.0", "marked": "^16.3.0", @@ -870,6 +871,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", @@ -1095,6 +1102,44 @@ } } }, + "node_modules/@vueuse/core": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz", + "integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.1.0", + "@vueuse/shared": "14.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz", + "integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz", + "integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/alien-signals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.0.0.tgz", diff --git a/pmoapp/webapp/package.json b/pmoapp/webapp/package.json index a69bb749..defd70bb 100644 --- a/pmoapp/webapp/package.json +++ b/pmoapp/webapp/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@vueuse/core": "^14.1.0", "dompurify": "^3.2.7", "lucide-vue-next": "^0.555.0", "marked": "^16.3.0", @@ -18,8 +19,8 @@ "vue-virtual-scroller": "^2.0.0-beta.8" }, "devDependencies": { - "@types/node": "^22.0.0", "@types/dompurify": "^3.0.5", + "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.8.1", "typescript": "~5.8.3", diff --git a/pmoapp/webapp/src/assets/styles/glass-theme.css b/pmoapp/webapp/src/assets/styles/glass-theme.css new file mode 100644 index 00000000..56dc8c76 --- /dev/null +++ b/pmoapp/webapp/src/assets/styles/glass-theme.css @@ -0,0 +1,430 @@ +/** + * Liquid Glass Theme - Glassmorphism Effects + * Style inspiré de macOS Big Sur et suivants + * Utilise backdrop-filter pour des effets de flou et transparence + */ + +/* ======================================== + CLASSES DE BASE GLASS + ======================================== */ + +/* Panel glass standard (fond clair) */ +.glass-panel { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: + 0 8px 32px 0 rgba(31, 38, 135, 0.15), + inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +/* Panel glass pour fond sombre */ +.glass-panel-dark { + background: rgba(0, 0, 0, 0.25); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 8px 32px 0 rgba(0, 0, 0, 0.37), + inset 0 1px 0 0 rgba(255, 255, 255, 0.15); +} + +/* Variantes d'intensité */ +.glass-subtle { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px) saturate(150%); + -webkit-backdrop-filter: blur(10px) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.glass-medium { + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(15px) saturate(170%); + -webkit-backdrop-filter: blur(15px) saturate(170%); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.glass-strong { + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(30px) saturate(200%); + -webkit-backdrop-filter: blur(30px) saturate(200%); + border: 1px solid rgba(255, 255, 255, 0.25); +} + +/* ======================================== + BOUTONS GLASS + ======================================== */ + +.glass-button { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px) saturate(150%); + -webkit-backdrop-filter: blur(10px) saturate(150%); + border: 1px solid rgba(255, 255, 255, 0.2); + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.glass-button:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); +} + +.glass-button:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.glass-button:disabled { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Bouton glass primary (avec couleur d'accent) */ +.glass-button-primary { + background: rgba(102, 126, 234, 0.3); + backdrop-filter: blur(10px) saturate(150%); + -webkit-backdrop-filter: blur(10px) saturate(150%); + border: 1px solid rgba(102, 126, 234, 0.5); + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.glass-button-primary:hover { + background: rgba(102, 126, 234, 0.4); + border-color: rgba(102, 126, 234, 0.6); +} + +/* ======================================== + INPUTS GLASS + ======================================== */ + +.glass-input { + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.15); + transition: all 0.3s ease; +} + +.glass-input:focus { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(102, 126, 234, 0.5); + outline: none; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.glass-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +/* ======================================== + CARTES GLASS + ======================================== */ + +.glass-card { + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(15px) saturate(170%); + -webkit-backdrop-filter: blur(15px) saturate(170%); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.glass-card:hover { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.18); + transform: translateY(-4px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); +} + +/* ======================================== + SLIDER GLASS (pour volume control) + ======================================== */ + +.glass-slider { + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 8px; + height: 8px; + outline: none; + transition: all 0.3s ease; +} + +.glass-slider:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Thumb Webkit (Chrome, Safari) */ +.glass-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.5); + border: 2px solid rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; +} + +.glass-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.glass-slider::-webkit-slider-thumb:active { + transform: scale(1.1); +} + +/* Thumb Mozilla */ +.glass-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.5); + border: 2px solid rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; +} + +.glass-slider::-moz-range-thumb:hover { + transform: scale(1.2); +} + +/* ======================================== + OVERLAY GLASS (pour CurrentTrack) + ======================================== */ + +.glass-overlay { + position: relative; + overflow: hidden; + border-radius: 16px; +} + +.glass-overlay::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(0, 0, 0, 0.3) 100%); + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px); + transition: backdrop-filter 0.3s ease; + pointer-events: none; +} + +.glass-overlay:hover::before { + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +/* ======================================== + UTILITAIRES + ======================================== */ + +/* Backdrop blur seul */ +.glass-blur { + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.glass-blur-sm { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.glass-blur-lg { + backdrop-filter: blur(30px); + -webkit-backdrop-filter: blur(30px); +} + +/* Saturation */ +.glass-saturate { + backdrop-filter: saturate(180%); + -webkit-backdrop-filter: saturate(180%); +} + +/* Bordures glass */ +.glass-border { + border: 1px solid rgba(255, 255, 255, 0.18); +} + +.glass-border-subtle { + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.glass-border-strong { + border: 1px solid rgba(255, 255, 255, 0.3); +} + +/* Shadow glass */ +.glass-shadow { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); +} + +.glass-shadow-lg { + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2); +} + +/* Inset shadow (lumière interne) */ +.glass-inset-light { + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.3); +} + +/* ======================================== + THÈME SOMBRE (prefers-color-scheme) + ======================================== */ + +@media (prefers-color-scheme: dark) { + .glass-panel { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.12); + } + + .glass-subtle { + background: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.08); + } + + .glass-medium { + background: rgba(0, 0, 0, 0.4); + border-color: rgba(255, 255, 255, 0.15); + } + + .glass-strong { + background: rgba(0, 0, 0, 0.5); + border-color: rgba(255, 255, 255, 0.2); + } + + .glass-button { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.15); + } + + .glass-button:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); + } + + .glass-card { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.1); + } + + .glass-card:hover { + background: rgba(0, 0, 0, 0.4); + border-color: rgba(255, 255, 255, 0.15); + } +} + +/* ======================================== + FALLBACK (pas de backdrop-filter) + ======================================== */ + +@supports not (backdrop-filter: blur(20px)) { + .glass-panel, + .glass-panel-dark, + .glass-subtle, + .glass-medium, + .glass-strong { + background: rgba(255, 255, 255, 0.95); + } + + @media (prefers-color-scheme: dark) { + .glass-panel, + .glass-panel-dark, + .glass-subtle, + .glass-medium, + .glass-strong { + background: rgba(0, 0, 0, 0.95); + } + } + + .glass-button, + .glass-button-primary { + background: rgba(255, 255, 255, 0.9); + } + + .glass-card { + background: rgba(255, 255, 255, 0.98); + } + + .glass-slider { + background: rgba(200, 200, 200, 0.8); + } +} + +/* ======================================== + ANIMATIONS + ======================================== */ + +@keyframes glassShimmer { + 0% { + 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; +} + +/* ======================================== + PERFORMANCE + ======================================== */ + +/* GPU acceleration pour les éléments glass animés */ +.glass-panel, +.glass-button, +.glass-card, +.glass-slider { + transform: translateZ(0); + backface-visibility: hidden; + will-change: transform, opacity; +} + +/* Réduire l'animation sur préférence système */ +@media (prefers-reduced-motion: reduce) { + .glass-button, + .glass-card, + .glass-slider::-webkit-slider-thumb, + .glass-slider::-moz-range-thumb { + transition: none; + } + + .glass-shimmer { + animation: none; + } +} diff --git a/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue index 1b5c8fd4..b1203ce8 100644 --- a/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue +++ b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue @@ -92,8 +92,103 @@ async function handleNext() { diff --git a/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue index 572248cd..63f765ed 100644 --- a/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue +++ b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue @@ -83,16 +83,116 @@ async function handleToggleMute() { gap: var(--spacing-md); width: 100%; max-width: 300px; + padding: var(--spacing-md) var(--spacing-lg); + border-radius: 20px; + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); } +@media (prefers-color-scheme: dark) { + .volume-control { + background: rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.12); + } +} + +/* Bouton mute avec effet glass */ +.volume-control .btn-icon { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 50%; + transition: all 0.3s ease; +} + +.volume-control .btn-icon:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.3); + transform: scale(1.1); +} + +.volume-control .btn-icon:active { + transform: scale(1.0); +} + +/* Slider avec effet glass */ .volume-slider { flex: 1; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-radius: 8px; + height: 8px; + outline: none; + transition: all 0.3s ease; +} + +.volume-slider:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.25); +} + +/* Thumb Webkit (Chrome, Safari) */ +.volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.5); + border: 2px solid rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; +} + +.volume-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.volume-slider::-webkit-slider-thumb:active { + transform: scale(1.1); +} + +/* Thumb Mozilla */ +.volume-slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.5); + border: 2px solid rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; +} + +.volume-slider::-moz-range-thumb:hover { + transform: scale(1.2); } .volume-value { font-size: var(--text-sm); font-weight: 600; - color: var(--color-text-secondary); + color: var(--color-text); min-width: 2.5rem; text-align: right; } @@ -100,5 +200,53 @@ async function handleToggleMute() { .volume-slider:disabled { opacity: 0.5; cursor: not-allowed; + background: rgba(255, 255, 255, 0.1); +} + +.volume-slider:disabled::-webkit-slider-thumb { + cursor: not-allowed; + background: rgba(255, 255, 255, 0.5); +} + +.volume-slider:disabled::-moz-range-thumb { + cursor: not-allowed; + background: rgba(255, 255, 255, 0.5); +} + +@media (prefers-color-scheme: dark) { + .volume-control .btn-icon { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.15); + } + + .volume-control .btn-icon:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); + } + + .volume-slider { + background: rgba(255, 255, 255, 0.15); + } +} + +/* Fallback pour navigateurs sans backdrop-filter */ +@supports not (backdrop-filter: blur(20px)) { + .volume-control { + background: rgba(255, 255, 255, 0.95); + } + + .volume-slider { + background: rgba(200, 200, 200, 0.8); + } + + @media (prefers-color-scheme: dark) { + .volume-control { + background: rgba(0, 0, 0, 0.95); + } + + .volume-slider { + background: rgba(100, 100, 100, 0.8); + } + } } diff --git a/pmoapp/webapp/src/components/unified/BottomTabBar.vue b/pmoapp/webapp/src/components/unified/BottomTabBar.vue new file mode 100644 index 00000000..937ad7b1 --- /dev/null +++ b/pmoapp/webapp/src/components/unified/BottomTabBar.vue @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + {{ tab.title }} + + + handleCloseClick(e, tab.id)" + :aria-label="`Close ${tab.title} tab`" + > + + + + + + + + + + + + + + diff --git a/pmoapp/webapp/src/components/unified/HomeTabContent.vue b/pmoapp/webapp/src/components/unified/HomeTabContent.vue new file mode 100644 index 00000000..6f4be302 --- /dev/null +++ b/pmoapp/webapp/src/components/unified/HomeTabContent.vue @@ -0,0 +1,304 @@ + + + + + + + PMOControl Dashboard + + + + {{ onlineRenderers.length }} / {{ renderers.length }} renderers + + + + {{ onlineServers.length }} / {{ mediaServers.length }} serveurs + + + + + + + + + + Renderers Audio + + + + + + + + + + + Aucun renderer découvert + Actualiser + + + + + + + + + Serveurs de Médias + + + + + + + + + + + Aucun serveur de médias découvert + Actualiser + + + + + + diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue new file mode 100644 index 00000000..43e5e557 --- /dev/null +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -0,0 +1,483 @@ + + + + + + + + {{ renderer?.friendly_name || 'Renderer' }} + {{ renderer.model_name }} + + + + + {{ renderer.protocol.toUpperCase() }} + + + + + + + + + + OFFLINE + + + + + + + + + + + + + + + + + + + + + + + Ce renderer est actuellement hors ligne + + + + + + + File d'attente + {{ queue.items.length }} morceaux + + + + + + Aucun morceau en file d'attente + + + + + + + + + + File d'attente ({{ queue?.items.length || 0 }}) + + + + + + + Aucun morceau en file d'attente + + + + + + + + + + + diff --git a/pmoapp/webapp/src/components/unified/ServerTabContent.vue b/pmoapp/webapp/src/components/unified/ServerTabContent.vue new file mode 100644 index 00000000..b47930d7 --- /dev/null +++ b/pmoapp/webapp/src/components/unified/ServerTabContent.vue @@ -0,0 +1,195 @@ + + + + + + + + {{ server?.friendly_name || 'Media Server' }} + {{ server.model_name }} + + + ONLINE + OFFLINE + + + + + + + + + Ce serveur est actuellement hors ligne + Actualiser + + + + + + diff --git a/pmoapp/webapp/src/composables/useTabs.ts b/pmoapp/webapp/src/composables/useTabs.ts new file mode 100644 index 00000000..371f62a9 --- /dev/null +++ b/pmoapp/webapp/src/composables/useTabs.ts @@ -0,0 +1,337 @@ +/** + * Composable pour gérer les onglets dynamiques de l'interface unifiée. + * Supporte les onglets home, renderer et server avec persistance localStorage. + */ +import { reactive, computed, watch, onMounted, type Component } from 'vue' +import { Home, Radio, Server } from 'lucide-vue-next' +import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types' + +export interface Tab { + id: string // "home", "renderer-{id}", "server-{id}" + type: 'home' | 'renderer' | 'server' + title: string // Nom affiché (tronqué sur mobile) + icon: Component + metadata?: { + rendererId?: string + serverId?: string + } + closeable: boolean // home tab non fermable +} + +interface TabsState { + tabs: Tab[] + activeTabId: string + tabHistory: string[] // Pour back/forward navigation +} + +const MAX_TABS = 8 +const STORAGE_KEY = 'pmo-tabs-state' + +// État global partagé entre toutes les instances du composable +const state = reactive({ + tabs: [ + { + id: 'home', + type: 'home', + title: 'Home', + icon: Home, + closeable: false, + }, + ], + activeTabId: 'home', + tabHistory: ['home'], +}) + +// Flag pour éviter les boucles de sauvegarde +let isRestoringFromStorage = false + +/** + * Tronque un titre pour mobile si nécessaire + */ +function truncateTitle(title: string, maxLength = 15): string { + return title.length > maxLength ? title.slice(0, maxLength) + '...' : title +} + +/** + * Sauvegarde l'état dans localStorage + */ +function saveToLocalStorage() { + if (isRestoringFromStorage) return + + try { + const stateToSave = { + tabs: state.tabs.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) + } +} + +/** + * Restaure l'état depuis localStorage + */ +function restoreFromLocalStorage() { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (!saved) return + + isRestoringFromStorage = true + const savedState = JSON.parse(saved) + + // Reconstituer les tabs avec les bonnes icônes + state.tabs = savedState.tabs.map((tab: Tab) => ({ + ...tab, + icon: tab.type === 'home' ? Home : tab.type === 'renderer' ? Radio : Server, + })) + + state.activeTabId = savedState.activeTabId || 'home' + state.tabHistory = savedState.tabHistory || ['home'] + + // Vérifier que l'onglet actif existe toujours + if (!state.tabs.find((t) => t.id === state.activeTabId)) { + state.activeTabId = 'home' + } + + isRestoringFromStorage = false + } catch (error) { + console.error('[useTabs] Erreur restauration localStorage:', error) + isRestoringFromStorage = false + } +} + +/** + * Trouve un onglet par son ID + */ +function findTab(tabId: string): Tab | undefined { + return state.tabs.find((t) => t.id === tabId) +} + +/** + * Ouvre un nouvel onglet ou active un onglet existant + */ +function openTab(newTab: Omit & { id?: string }): string { + // Générer un ID si non fourni + const tabId = + newTab.id || + (newTab.type === 'home' + ? 'home' + : newTab.type === 'renderer' + ? `renderer-${newTab.metadata?.rendererId}` + : `server-${newTab.metadata?.serverId}`) + + // Si l'onglet existe déjà, on le sélectionne + const existingTab = findTab(tabId) + if (existingTab) { + switchTab(tabId) + return tabId + } + + // Vérifier la limite max + if (state.tabs.length >= MAX_TABS) { + console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`) + return state.activeTabId + } + + // Créer le nouvel onglet + const tab: Tab = { + id: tabId, + type: newTab.type, + title: truncateTitle(newTab.title), + icon: newTab.icon, + metadata: newTab.metadata, + closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false + } + + state.tabs.push(tab) + switchTab(tabId) + + return tabId +} + +/** + * Ferme un onglet + */ +function closeTab(tabId: string) { + const tab = findTab(tabId) + if (!tab) return + + // Ne pas fermer l'onglet home + if (!tab.closeable) { + console.warn('[useTabs] Impossible de fermer un onglet non fermable') + return + } + + const tabIndex = state.tabs.findIndex((t) => t.id === tabId) + if (tabIndex === -1) return + + // Supprimer l'onglet + state.tabs.splice(tabIndex, 1) + + // Supprimer de l'historique + state.tabHistory = state.tabHistory.filter((id) => id !== tabId) + + // Si c'était l'onglet actif, basculer vers le précédent dans l'historique + if (state.activeTabId === tabId) { + // Chercher le précédent dans l'historique qui existe encore + const previousTab = state.tabHistory + .slice() + .reverse() + .find((id) => state.tabs.some((t) => t.id === id)) + + if (previousTab) { + switchTab(previousTab) + } else { + // Fallback sur home + switchTab('home') + } + } +} + +/** + * Change l'onglet actif + */ +function switchTab(tabId: string) { + const tab = findTab(tabId) + if (!tab) { + console.warn(`[useTabs] Onglet ${tabId} introuvable`) + return + } + + state.activeTabId = tabId + + // Ajouter à l'historique (en évitant les doublons consécutifs) + if (state.tabHistory[state.tabHistory.length - 1] !== tabId) { + state.tabHistory.push(tabId) + + // Limiter la taille de l'historique + if (state.tabHistory.length > 20) { + state.tabHistory.shift() + } + } +} + +/** + * Onglet suivant (pour swipe gesture) + */ +function nextTab() { + const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId) + const nextIndex = (currentIndex + 1) % state.tabs.length + const nextTabObj = state.tabs[nextIndex] + if (nextTabObj) switchTab(nextTabObj.id) +} + +/** + * Onglet précédent (pour swipe gesture) + */ +function previousTab() { + const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId) + const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1 + const prevTabObj = state.tabs[previousIndex] + if (prevTabObj) switchTab(prevTabObj.id) +} + +/** + * Ouvre un onglet renderer + */ +function openRenderer(renderer: RendererSummary | undefined) { + if (!renderer) return 'home' + + return openTab({ + type: 'renderer', + title: renderer.friendly_name, + icon: Radio, + metadata: { rendererId: renderer.id }, + closeable: true, + }) +} + +/** + * Ouvre un onglet server + */ +function openServer(server: MediaServerSummary | undefined) { + if (!server) return 'home' + + return openTab({ + type: 'server', + title: server.friendly_name, + icon: Server, + metadata: { serverId: server.id }, + closeable: true, + }) +} + +/** + * Ferme tous les onglets sauf home + */ +function closeAllTabs() { + state.tabs = state.tabs.filter((t) => !t.closeable) + state.activeTabId = 'home' + state.tabHistory = ['home'] +} + +/** + * Ferme tous les onglets sauf l'actif + */ +function closeOtherTabs() { + const activeTab = findTab(state.activeTabId) + if (!activeTab) return + + state.tabs = state.tabs.filter((t) => !t.closeable || t.id === state.activeTabId) + state.tabHistory = state.tabHistory.filter((id) => state.tabs.some((t) => t.id === id)) +} + +/** + * Composable principal + */ +export function useTabs() { + // Watch pour sauvegarde automatique + watch( + () => [state.tabs, state.activeTabId, state.tabHistory], + () => { + saveToLocalStorage() + }, + { deep: true }, + ) + + // Restaurer au montage (une seule fois) + onMounted(() => { + const firstTab = state.tabs[0] + if (state.tabs.length === 1 && firstTab && firstTab.id === 'home') { + restoreFromLocalStorage() + } + }) + + // Computed properties + const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null) + const hasMultipleTabs = computed(() => state.tabs.length > 1) + const canAddTab = computed(() => state.tabs.length < MAX_TABS) + + return { + // State + tabs: computed(() => state.tabs), + activeTabId: computed(() => state.activeTabId), + activeTab, + tabHistory: computed(() => state.tabHistory), + hasMultipleTabs, + canAddTab, + + // Actions + openTab, + closeTab, + switchTab, + nextTab, + previousTab, + openRenderer, + openServer, + closeAllTabs, + closeOtherTabs, + findTab, + } +} diff --git a/pmoapp/webapp/src/main.ts b/pmoapp/webapp/src/main.ts index db6a5a4a..39b3f00d 100644 --- a/pmoapp/webapp/src/main.ts +++ b/pmoapp/webapp/src/main.ts @@ -13,6 +13,7 @@ import { useUIStore } from "./stores/ui"; import "./style.css"; import "./assets/styles/variables.css"; import "./assets/styles/pmocontrol.css"; +import "./assets/styles/glass-theme.css"; // Créer l'application Vue const app = createApp(App); diff --git a/pmoapp/webapp/src/router/index.ts b/pmoapp/webapp/src/router/index.ts index 295549c0..9e6f2ccf 100644 --- a/pmoapp/webapp/src/router/index.ts +++ b/pmoapp/webapp/src/router/index.ts @@ -1,6 +1,9 @@ import { createRouter, createWebHistory } from "vue-router"; -// PMOControl Views (nouvelle home) +// PMOControl Unified View (nouvelle interface unifiée) +import UnifiedControlView from "../views/UnifiedControlView.vue"; + +// PMOControl Views (anciennes vues - conservées pour fallback) import DashboardView from "../views/DashboardView.vue"; import RendererView from "../views/RendererView.vue"; import MediaServerView from "../views/MediaServerView.vue"; @@ -16,19 +19,26 @@ import APIDashboard from "../components/APIDashboard.vue"; import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue"; const routes = [ - // PMOControl (nouvelle home) + // PMOControl Unified Interface (nouvelle interface unifiée avec onglets) { path: "/", + name: "Control", + component: UnifiedControlView, + }, + + // Anciennes routes (conservées sous /legacy pour fallback) + { + path: "/legacy", name: "Dashboard", component: DashboardView, }, { - path: "/renderer/:id", + path: "/legacy/renderer/:id", name: "Renderer", component: RendererView, }, { - path: "/server/:serverId", + path: "/legacy/server/:serverId", name: "MediaServer", component: MediaServerView, }, diff --git a/pmoapp/webapp/src/views/UnifiedControlView.vue b/pmoapp/webapp/src/views/UnifiedControlView.vue new file mode 100644 index 00000000..391386cb --- /dev/null +++ b/pmoapp/webapp/src/views/UnifiedControlView.vue @@ -0,0 +1,209 @@ + + + + + + + + + + + + + + + + +
Aucun renderer découvert
Aucun serveur de médias découvert
{{ renderer.model_name }}
Ce renderer est actuellement hors ligne
Aucun morceau en file d'attente
{{ server.model_name }}
Ce serveur est actuellement hors ligne