Files
pmomusic/pmoapp/webapp/src/composables/useTabs.ts
Eric Coissac 6b5282903e
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m52s
Grosse correction sur l'interface graphique et débugage de Open Home.
2025-12-27 13:46:17 +01:00

383 lines
11 KiB
TypeScript

/**
* Composable pour gérer les onglets dynamiques de l'interface unifiée.
* Onglets renderer auto-générés depuis la liste des renderers online.
* Onglets server ouverts manuellement via le drawer (fermables).
*/
import { reactive, computed, watch, onMounted, type Component } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { Radio, Server } from 'lucide-vue-next'
import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types'
export interface Tab {
id: string // "renderer-{id}", "server-{id}"
type: 'renderer' | 'server'
title: string // Nom affiché (tronqué sur mobile)
fullTitle: string // Nom complet (pour tooltip)
icon: Component
metadata?: {
rendererId?: string
serverId?: string
}
closeable: boolean // renderer: false (auto-géré), server: true (manuel)
}
interface TabsState {
tabs: Tab[]
activeTabId: string
tabHistory: string[] // Pour back/forward navigation
}
const MAX_TABS = 12 // Augmenté car onglets auto-générés
const STORAGE_KEY = 'pmo-tabs-state'
// État global partagé entre toutes les instances du composable
const state = reactive<TabsState>({
tabs: [],
activeTabId: '',
tabHistory: [],
})
// Flag pour éviter les boucles de sauvegarde
let isRestoringFromStorage = false
/**
* Retourne le titre complet sans troncature
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
*/
function truncateTitle(title: string): string {
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
return title
}
/**
* Sauvegarde l'état dans localStorage
* 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)
}
}
/**
* Restaure l'état depuis localStorage
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
*/
function restoreFromLocalStorage() {
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
const serverTabs = (savedState.tabs || [])
.filter((tab: Tab) => tab.type === 'server')
.map((tab: Tab) => ({
...tab,
icon: Server,
closeable: true,
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
}))
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
state.tabs.push(...serverTabs)
state.activeTabId = savedState.activeTabId || ''
state.tabHistory = savedState.tabHistory || []
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
state.activeTabId = ''
}
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<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitle?: string }): string {
// Générer un ID si non fourni
const tabId =
newTab.id ||
(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),
fullTitle: newTab.fullTitle || newTab.title, // Utiliser fullTitle si fourni, sinon 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 (uniquement les onglets server manuels)
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
*/
function closeTab(tabId: string) {
const tab = findTab(tabId)
if (!tab) return
// Ne fermer que les onglets server (closeable = true)
// Les renderer tabs sont auto-gérés par syncWithRenderers
if (!tab.closeable || tab.type === 'renderer') {
console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)')
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 le premier onglet disponible (ou vide)
const firstTab = state.tabs[0]
if (firstTab) {
switchTab(firstTab.id)
} else {
state.activeTabId = ''
}
}
}
}
/**
* 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)
}
/**
* Synchronise les onglets avec la liste des renderers online
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
*/
function syncWithRenderers(renderers: RendererSummary[]) {
const onlineRenderers = renderers.filter((r) => r.online)
// Extraire les tabs renderer actuels
const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer')
// IDs des renderers online
const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`))
// Supprimer les tabs des renderers qui ne sont plus online
const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id))
renderersToRemove.forEach((tab) => {
const index = state.tabs.findIndex((t) => t.id === tab.id)
if (index !== -1) state.tabs.splice(index, 1)
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id)
})
// Ajouter les nouveaux renderers online
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id))
onlineRenderers.forEach((renderer) => {
const tabId = `renderer-${renderer.id}`
if (!currentRendererIds.has(tabId)) {
const newTab: Tab = {
id: tabId,
type: 'renderer',
title: truncateTitle(renderer.friendly_name),
fullTitle: renderer.friendly_name,
icon: Radio,
metadata: { rendererId: renderer.id },
closeable: false, // renderer tabs ne sont pas fermables manuellement
}
state.tabs.unshift(newTab) // Ajouter au début
}
})
// Vérifier que l'onglet actif existe toujours
if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) {
// Basculer vers le premier onglet disponible
const firstTab = state.tabs[0]
if (firstTab) {
state.activeTabId = firstTab.id
} else {
state.activeTabId = ''
}
}
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
if (!state.activeTabId && state.tabs.length > 0) {
const firstTab = state.tabs[0]
if (firstTab) {
state.activeTabId = firstTab.id
}
}
}
/**
* Ouvre un onglet server (manuel)
*/
function openServer(server: MediaServerSummary | undefined) {
if (!server) return ''
return openTab({
type: 'server',
title: server.friendly_name,
icon: Server,
metadata: { serverId: server.id },
closeable: true,
})
}
/**
* Composable principal
*/
export function useTabs() {
// Watch pour sauvegarde automatique (uniquement les server tabs)
watch(
() => [state.tabs, state.activeTabId, state.tabHistory],
() => {
saveToLocalStorage()
},
{ deep: true },
)
// Restaurer au montage (uniquement les server tabs)
onMounted(() => {
// Restaurer seulement si pas déjà fait
if (state.tabs.filter((t) => t.type === 'server').length === 0) {
restoreFromLocalStorage()
}
})
// Détection de la largeur d'écran pour le mode compact
// Mode compact sur les écrans < 900px (tablettes et mobiles)
const isNarrowScreen = useMediaQuery('(max-width: 900px)')
// 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)
const isEmpty = computed(() => state.tabs.length === 0)
const compactMode = computed(() => isNarrowScreen.value)
return {
// State
tabs: computed(() => state.tabs),
activeTabId: computed(() => state.activeTabId),
activeTab,
tabHistory: computed(() => state.tabHistory),
hasMultipleTabs,
canAddTab,
isEmpty,
compactMode,
// Actions
syncWithRenderers, // Nouvelle fonction clé pour auto-sync
openTab,
closeTab,
switchTab,
nextTab,
previousTab,
openServer,
findTab,
}
}