Implémentation du menu déroulant pour les actions d'éléments dans le ServerDrawer

Ajout d'un menu déroulant avec les actions 'Ajouter à la queue' et 'Ajouter après' pour les éléments dans le ServerDrawer.

- Remplacement des fonctions handlePlayItem et handleQueueItem par des fonctions plus spécifiques (handlePlayItem, handleAddToQueue, handleAddAfterCurrent)
- Ajout d'une gestion du menu déroulant avec ouverture/fermeture
- Intégration du composant useRenderers pour les actions de lecture
- Mise à jour des styles pour le menu déroulant avec animations
- Correction de l'alignement du backdrop sur mobile
- Amélioration des transitions et animations du drawer
- Ajout de la fonction addAfterCurrent dans l'API et le control point
- Support de l'ajout d'éléments après le morceau actuel dans les queues
This commit is contained in:
2026-01-09 14:30:53 +01:00
parent d708bb6035
commit 38f49d4439
11 changed files with 950 additions and 492 deletions

View File

@@ -4,28 +4,28 @@
* - Les snapshots complets proviennent de /renderers/{id}/full
* - Les événements SSE ne servent qu'à déclencher un refetch.
*/
import { ref, reactive, computed, type Ref } from 'vue'
import { api } from '../services/pmocontrol/api'
import { sse } from '../services/pmocontrol/sse'
import { ref, reactive, computed, type Ref } from "vue";
import { api } from "../services/pmocontrol/api";
import { sse } from "../services/pmocontrol/sse";
import type {
RendererSummary,
RendererState,
QueueSnapshot,
AttachedPlaylistInfo,
FullRendererSnapshot,
} from '../services/pmocontrol/types'
} from "../services/pmocontrol/types";
interface RendererSnapshotState {
snapshots: Map<string, FullRendererSnapshot>
lastSnapshotAt: Map<string, number>
lastEventAt: Map<string, number>
loadingIds: Set<string>
selectedRendererId: string | null
snapshots: Map<string, FullRendererSnapshot>;
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
selectedRendererId: string | null;
}
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
const RENDERERS_CACHE_MS = 2000
const lastRenderersFetch = ref(0)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
const lastRenderersFetch = ref(0);
const snapshotState = reactive<RendererSnapshotState>({
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
@@ -33,23 +33,25 @@ const snapshotState = reactive<RendererSnapshotState>({
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
selectedRendererId: null,
})
});
const loading = ref(false)
const error = ref<string | null>(null)
const loading = ref(false);
const error = ref<string | null>(null);
let sseConnected = false
let sseConnected = false;
function ensureSSEConnected() {
if (sseConnected) return
if (sseConnected) return;
sse.onRendererEvent((event) => {
const rendererId = event.renderer_id
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
const rendererId = event.renderer_id;
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
// Gérer les événements Online/Offline différemment
if (event.type === 'online') {
if (event.type === "online") {
// Nouveau renderer découvert
console.log(`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`)
console.log(
`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`,
);
// Ajouter au cache avec les infos disponibles
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
@@ -57,7 +59,7 @@ function ensureSSEConnected() {
id: rendererId,
friendly_name: event.friendly_name,
model_name: event.model_name,
protocol: 'upnp', // Valeur par défaut, sera mise à jour par le fetch
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
capabilities: {
has_avtransport: false,
has_avtransport_set_next: false,
@@ -72,187 +74,200 @@ function ensureSSEConnected() {
has_oh_radio: false,
},
online: true,
}
renderersCache.value.set(rendererId, renderer)
};
renderersCache.value.set(rendererId, renderer);
// Fetch la liste complète pour avoir les bonnes infos
void fetchRenderers(true)
void fetchRenderers(true);
// Fetch le snapshot complet pour ce renderer
void fetchRendererSnapshot(rendererId, { force: true })
return
void fetchRendererSnapshot(rendererId, { force: true });
return;
}
if (event.type === 'offline') {
if (event.type === "offline") {
// Renderer déconnecté
console.log(`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`)
console.log(
`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`,
);
// Marquer comme offline dans le cache
const renderer = renderersCache.value.get(rendererId)
const renderer = renderersCache.value.get(rendererId);
if (renderer) {
renderer.online = false
renderersCache.value.set(rendererId, renderer)
renderer.online = false;
renderersCache.value.set(rendererId, renderer);
}
// Supprimer le snapshot (il n'est plus valide)
snapshotState.snapshots.delete(rendererId)
snapshotState.lastSnapshotAt.delete(rendererId)
snapshotState.lastEventAt.delete(rendererId)
return
snapshotState.snapshots.delete(rendererId);
snapshotState.lastSnapshotAt.delete(rendererId);
snapshotState.lastEventAt.delete(rendererId);
return;
}
// Pour les autres événements, comportement existant
snapshotState.lastEventAt.set(rendererId, timestamp)
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
snapshotState.lastEventAt.set(rendererId, timestamp);
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
void fetchRendererSnapshot(rendererId, { force: true })
void fetchRendererSnapshot(rendererId, { force: true });
}
})
});
sseConnected = true
sseConnected = true;
}
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
const onlineRenderers = computed(() =>
allRenderers.value.filter((r) => r.online),
);
const allSnapshots = computed(() =>
Array.from(snapshotState.snapshots.values()),
);
const playingRenderers = computed(() =>
allSnapshots.value
.filter((snapshot) => snapshot.state.transport_state === 'PLAYING')
.filter((snapshot) => snapshot.state.transport_state === "PLAYING")
.map((snapshot) => snapshot.state),
)
);
function getRendererById(id: string) {
return renderersCache.value.get(id)
return renderersCache.value.get(id);
}
function getSnapshotById(id: string) {
return snapshotState.snapshots.get(id) ?? null
return snapshotState.snapshots.get(id) ?? null;
}
function getStateById(id: string): RendererState | null {
return snapshotState.snapshots.get(id)?.state ?? null
return snapshotState.snapshots.get(id)?.state ?? null;
}
function getQueueById(id: string): QueueSnapshot | null {
return snapshotState.snapshots.get(id)?.queue ?? null
return snapshotState.snapshots.get(id)?.queue ?? null;
}
function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshotState.snapshots.get(id)?.binding ?? null
return snapshotState.snapshots.get(id)?.binding ?? null;
}
function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id)
return snapshotState.loadingIds.has(id);
}
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id
snapshotState.selectedRendererId = id;
}
async function fetchRenderers(force = false) {
ensureSSEConnected()
ensureSSEConnected();
const now = Date.now()
const now = Date.now();
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
return
return;
}
try {
loading.value = true
error.value = null
const data = await api.getRenderers()
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
lastRenderersFetch.value = now
loading.value = true;
error.value = null;
const data = await api.getRenderers();
renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]),
);
lastRenderersFetch.value = now;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur fetch renderers'
console.error('[useRenderers] Erreur fetch:', err)
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
console.error("[useRenderers] Erreur fetch:", err);
} finally {
loading.value = false
loading.value = false;
}
}
async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) {
ensureSSEConnected()
const force = opts?.force ?? false
const hasSnapshot = snapshotState.snapshots.has(rendererId)
async function fetchRendererSnapshot(
rendererId: string,
opts?: { force?: boolean },
) {
ensureSSEConnected();
const force = opts?.force ?? false;
const hasSnapshot = snapshotState.snapshots.has(rendererId);
if (!force && hasSnapshot) {
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
if (lastEvent <= lastSnapshot) {
return
return;
}
}
if (snapshotState.loadingIds.has(rendererId)) {
return
return;
}
snapshotState.loadingIds.add(rendererId)
snapshotState.loadingIds.add(rendererId);
try {
const snapshot = await api.getRendererFullSnapshot(rendererId)
snapshotState.snapshots.set(rendererId, snapshot)
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshotState.snapshots.set(rendererId, snapshot);
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
} finally {
snapshotState.loadingIds.delete(rendererId)
snapshotState.loadingIds.delete(rendererId);
}
}
// Transport controls
async function play(id: string) {
await api.play(id)
await api.play(id);
}
async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshotState.snapshots.get(id)
const snapshot = snapshotState.snapshots.get(id);
if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`)
throw new Error(`Renderer ${id} non trouvé`);
}
const state = snapshot.state
if (state.transport_state === 'PAUSED') {
return play(id)
const state = snapshot.state;
if (state.transport_state === "PAUSED") {
return play(id);
}
if (
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
snapshot.queue.items.length > 0
) {
return api.resume(id)
return api.resume(id);
}
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
throw new Error(
"La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.",
);
}
async function pause(id: string) {
await api.pause(id)
await api.pause(id);
}
async function stop(id: string) {
await api.stop(id)
await api.stop(id);
}
async function next(id: string) {
await api.next(id)
await api.next(id);
}
// Volume controls
async function setVolume(id: string, volume: number) {
await api.setVolume(id, volume)
await api.setVolume(id, volume);
}
async function volumeUp(id: string) {
await api.volumeUp(id)
await api.volumeUp(id);
}
async function volumeDown(id: string) {
await api.volumeDown(id)
await api.volumeDown(id);
}
async function toggleMute(id: string) {
await api.toggleMute(id)
await api.toggleMute(id);
}
// Playlist binding
@@ -262,11 +277,16 @@ async function attachPlaylist(
containerId: string,
options?: { autoPlay?: boolean },
) {
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
await api.attachPlaylist(
rendererId,
serverId,
containerId,
options?.autoPlay ?? false,
);
}
async function detachPlaylist(rendererId: string) {
await api.detachPlaylist(rendererId)
await api.detachPlaylist(rendererId);
}
async function attachAndPlayPlaylist(
@@ -274,20 +294,36 @@ async function attachAndPlayPlaylist(
serverId: string,
containerId: string,
) {
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true });
}
// Queue content
async function playContent(rendererId: string, serverId: string, objectId: string) {
await api.playContent(rendererId, serverId, objectId)
async function playContent(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.playContent(rendererId, serverId, objectId);
}
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
await api.addToQueue(rendererId, serverId, objectId)
async function addToQueue(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.addToQueue(rendererId, serverId, objectId);
}
async function addAfterCurrent(
rendererId: string,
serverId: string,
objectId: string,
) {
await api.addAfterCurrent(rendererId, serverId, objectId);
}
export function useRenderers() {
ensureSSEConnected()
ensureSSEConnected();
return {
loading,
@@ -326,23 +362,26 @@ export function useRenderers() {
// Queue content
playContent,
addToQueue,
}
addAfterCurrent,
};
}
export function useRenderer(rendererId: Ref<string>) {
ensureSSEConnected()
ensureSSEConnected();
const renderer = computed(() => renderersCache.value.get(rendererId.value))
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
const state = computed(() => snapshot.value?.state ?? null)
const queue = computed(() => snapshot.value?.queue ?? null)
const binding = computed(() => snapshot.value?.binding ?? null)
const renderer = computed(() => renderersCache.value.get(rendererId.value));
const snapshot = computed(
() => snapshotState.snapshots.get(rendererId.value) ?? null,
);
const state = computed(() => snapshot.value?.state ?? null);
const queue = computed(() => snapshot.value?.queue ?? null);
const binding = computed(() => snapshot.value?.binding ?? null);
async function refresh(force = true) {
await Promise.all([
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
])
]);
}
return {
@@ -352,5 +391,5 @@ export function useRenderer(rendererId: Ref<string>) {
queue,
binding,
refresh,
}
};
}