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:
@@ -12,8 +12,10 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
} from "lucide-vue-next";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
@@ -22,12 +24,11 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
selectedRendererId?: string | null; // ID du renderer sélectionné
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
"play-item": [item: ContainerEntry, serverId: string];
|
||||
"queue-item": [item: ContainerEntry, serverId: string];
|
||||
}>();
|
||||
|
||||
const {
|
||||
@@ -38,6 +39,10 @@ const {
|
||||
setPath,
|
||||
clearPath,
|
||||
} = useMediaServers();
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
useRenderers();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
@@ -45,6 +50,9 @@ const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -56,6 +64,34 @@ watch(
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
clearPath();
|
||||
closeMenu();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Fermer le menu quand on clique ailleurs (utilise un seul listener global)
|
||||
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
watch(
|
||||
() => openMenuId.value,
|
||||
(menuId) => {
|
||||
// Nettoyer l'ancien listener s'il existe
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
clickOutsideHandler = null;
|
||||
}
|
||||
|
||||
// Ajouter un nouveau listener seulement si un menu est ouvert
|
||||
if (menuId) {
|
||||
clickOutsideHandler = () => {
|
||||
closeMenu();
|
||||
};
|
||||
// Utiliser setTimeout pour éviter que le clic qui ouvre le menu le ferme immédiatement
|
||||
setTimeout(() => {
|
||||
if (clickOutsideHandler) {
|
||||
document.addEventListener("click", clickOutsideHandler);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -169,16 +205,85 @@ function handleItemClick(item: ContainerEntry) {
|
||||
// Les tracks individuels : on ne fait rien (actions via boutons)
|
||||
}
|
||||
|
||||
function handlePlayItem(event: Event, item: ContainerEntry) {
|
||||
function toggleMenu(itemId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
if (!currentServer.value) return;
|
||||
emit("play-item", item, currentServer.value.id);
|
||||
openMenuId.value = openMenuId.value === itemId ? null : itemId;
|
||||
}
|
||||
|
||||
function handleQueueItem(event: Event, item: ContainerEntry) {
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
async function handlePlayItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
if (!currentServer.value) return;
|
||||
emit("queue-item", item, currentServer.value.id);
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (item.is_container) {
|
||||
// Container : attacher comme playlist avec auto_play
|
||||
await attachAndPlayPlaylist(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} else {
|
||||
// Item : vider queue + ajouter + jouer
|
||||
await playContent(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error playing item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToQueue(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Détacher le binding (fait côté serveur) + ajouter à la fin
|
||||
await addToQueue(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error adding to queue:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAfterCurrent(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Détacher le binding + insérer après current
|
||||
await addAfterCurrent(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error adding after current:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
@@ -358,6 +463,7 @@ function handleSettingsClick() {
|
||||
:class="{
|
||||
navigable:
|
||||
item.is_container && isNavigable(item),
|
||||
'menu-open': openMenuId === item.id,
|
||||
}"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
@@ -433,6 +539,7 @@ function handleSettingsClick() {
|
||||
v-if="isPlayable(item)"
|
||||
class="content-actions"
|
||||
>
|
||||
<!-- Bouton Play -->
|
||||
<button
|
||||
class="action-btn play-btn"
|
||||
:title="
|
||||
@@ -444,17 +551,54 @@ function handleSettingsClick() {
|
||||
>
|
||||
<Play :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Bouton Menu avec dropdown -->
|
||||
<div class="action-menu-container">
|
||||
<button
|
||||
class="action-btn queue-btn"
|
||||
:title="
|
||||
item.is_container
|
||||
? 'Ajouter tout à la queue'
|
||||
: 'Ajouter à la queue'
|
||||
"
|
||||
@click="handleQueueItem($event, item)"
|
||||
class="action-btn menu-btn"
|
||||
:title="'Plus d\'actions'"
|
||||
@click="toggleMenu(item.id, $event)"
|
||||
>
|
||||
<Plus :size="16" />
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<Transition name="menu-fade">
|
||||
<div
|
||||
v-if="openMenuId === item.id"
|
||||
class="action-dropdown"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
handleAddToQueue(
|
||||
$event,
|
||||
item,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
<span
|
||||
>Ajouter à la
|
||||
queue</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
handleAddAfterCurrent(
|
||||
$event,
|
||||
item,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
<span>Ajouter après</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron pour containers navigables (même si jouables) -->
|
||||
@@ -507,7 +651,8 @@ function handleSettingsClick() {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
left: 80vw; /* Mobile portrait: 80vw */
|
||||
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,11 +662,11 @@ function handleSettingsClick() {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -794,6 +939,10 @@ function handleSettingsClick() {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.content-item.menu-open {
|
||||
z-index: 200; /* Passe au-dessus des autres items quand son menu est ouvert */
|
||||
}
|
||||
|
||||
/* Cover avec image */
|
||||
.content-cover {
|
||||
position: relative;
|
||||
@@ -884,7 +1033,8 @@ function handleSettingsClick() {
|
||||
flex-shrink: 0;
|
||||
opacity: 1; /* Toujours visible pour le tactile */
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1; /* Au-dessus pour capturer les clicks */
|
||||
z-index: 10; /* Au-dessus pour capturer les clicks */
|
||||
position: relative; /* Crée un contexte de stacking */
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -923,15 +1073,92 @@ function handleSettingsClick() {
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.queue-btn {
|
||||
.menu-btn {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.queue-btn:hover {
|
||||
.menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Menu dropdown container */
|
||||
.action-menu-container {
|
||||
position: relative;
|
||||
z-index: 100; /* Plus élevé que les content-item pour que le dropdown passe au-dessus */
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.action-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px;
|
||||
z-index: 10000; /* Très haut pour passer au-dessus de tout */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.action-dropdown {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdown items */
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.dropdown-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Menu fade animation */
|
||||
.menu-fade-enter-active {
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.menu-fade-leave-active {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.menu-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
}
|
||||
|
||||
.menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
@@ -1017,12 +1244,12 @@ function handleSettingsClick() {
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
transition-delay: 0.15s; /* Attend que le drawer soit à moitié visible */
|
||||
transition: opacity 0.3s ease-out;
|
||||
transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */
|
||||
}
|
||||
|
||||
.backdrop-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.25s ease-in;
|
||||
/* Pas de delay au leave - disparaît en même temps que le drawer */
|
||||
}
|
||||
|
||||
@@ -1031,16 +1258,26 @@ function handleSettingsClick() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
.drawer-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Courbe d'animation fluide (easeOutExpo) */
|
||||
}
|
||||
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.7, 0, 0.84, 0); /* Courbe d'animation de sortie (easeInExpo) */
|
||||
}
|
||||
|
||||
.drawer-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Animation des contenus - désactivée pour éviter les problèmes de z-index en escalier */
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar,
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
@@ -1068,7 +1305,14 @@ function handleSettingsClick() {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 80vw; /* Mobile portrait: 80% de l'écran */
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
@@ -1078,6 +1322,16 @@ function handleSettingsClick() {
|
||||
.drawer-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* Ajuster les items pour mobile */
|
||||
.content-item {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.content-cover {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,40 +13,40 @@ import type {
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse
|
||||
} from './types'
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = '/api/control'
|
||||
private readonly baseURL = "/api/control";
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`
|
||||
const url = `${this.baseURL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json().catch(() => ({
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.error)
|
||||
}));
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -58,7 +58,7 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers
|
||||
*/
|
||||
async getRenderers(): Promise<RendererSummary[]> {
|
||||
return this.request<RendererSummary[]>('/renderers')
|
||||
return this.request<RendererSummary[]>("/renderers");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +66,7 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}
|
||||
*/
|
||||
async getRendererState(id: string): Promise<RendererState> {
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +74,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/full
|
||||
*/
|
||||
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
|
||||
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
|
||||
return this.request<FullRendererSnapshot>(
|
||||
`/renderers/${encodeURIComponent(id)}/full`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +84,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/queue
|
||||
*/
|
||||
async getQueue(id: string): Promise<QueueSnapshot> {
|
||||
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
|
||||
return this.request<QueueSnapshot>(
|
||||
`/renderers/${encodeURIComponent(id)}/queue`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +94,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/binding
|
||||
*/
|
||||
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
|
||||
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
|
||||
return this.request<AttachedPlaylistInfo | null>(
|
||||
`/renderers/${encodeURIComponent(id)}/binding`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -102,9 +108,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/play
|
||||
*/
|
||||
async play(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/play`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +121,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/pause
|
||||
*/
|
||||
async pause(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/pause`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,9 +134,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/stop
|
||||
*/
|
||||
async stop(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/stop`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,9 +147,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/resume
|
||||
*/
|
||||
async resume(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/resume`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,9 +160,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/next
|
||||
*/
|
||||
async next(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/next`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,10 +173,13 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
*/
|
||||
async seekQueueIndex(id: string, index: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/queue/seek`, {
|
||||
method: 'POST',
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/queue/seek`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ index }),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -167,11 +191,14 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/set
|
||||
*/
|
||||
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
|
||||
const payload: VolumeSetRequest = { volume }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
|
||||
method: 'POST',
|
||||
const payload: VolumeSetRequest = { volume };
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/set`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,9 +206,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/up
|
||||
*/
|
||||
async volumeUp(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/up`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,9 +219,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/down
|
||||
*/
|
||||
async volumeDown(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/down`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,9 +232,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/mute/toggle
|
||||
*/
|
||||
async toggleMute(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/mute/toggle`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -216,17 +252,20 @@ class PMOControlAPI {
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
autoPlay = false
|
||||
autoPlay = false,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: AttachPlaylistRequest = {
|
||||
server_id: serverId,
|
||||
container_id: containerId,
|
||||
auto_play: autoPlay,
|
||||
}
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||
method: 'POST',
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/binding/attach`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,9 +273,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/binding/detach
|
||||
*/
|
||||
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/binding/detach`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -250,13 +292,19 @@ class PMOControlAPI {
|
||||
async playContent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, {
|
||||
method: 'POST',
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/play`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,13 +314,41 @@ class PMOControlAPI {
|
||||
async addToQueue(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, {
|
||||
method: 'POST',
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/add`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter du contenu après le morceau actuel
|
||||
* POST /api/control/renderers/{id}/queue/add-after
|
||||
*/
|
||||
async addAfterCurrent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/add-after`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -284,19 +360,22 @@ class PMOControlAPI {
|
||||
* GET /api/control/servers
|
||||
*/
|
||||
async getServers(): Promise<MediaServerSummary[]> {
|
||||
return this.request<MediaServerSummary[]>('/servers')
|
||||
return this.request<MediaServerSummary[]>("/servers");
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse le contenu d'un container sur un serveur
|
||||
* GET /api/control/servers/{serverId}/containers/{containerId}
|
||||
*/
|
||||
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
|
||||
)
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const api = new PMOControlAPI()
|
||||
export const api = new PMOControlAPI();
|
||||
|
||||
@@ -1,186 +1,147 @@
|
||||
<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 { useSwipe } from '@vueuse/core'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
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 { 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 RendererTabContent from '@/components/unified/RendererTabContent.vue'
|
||||
import ServerTabContent from '@/components/unified/ServerTabContent.vue'
|
||||
import BottomTabBar from "@/components/unified/BottomTabBar.vue";
|
||||
import EmptyState from "@/components/unified/EmptyState.vue";
|
||||
import ServerDrawer from "@/components/unified/ServerDrawer.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 } = useRenderers()
|
||||
const { allServers, fetchServers } = useMediaServers()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
|
||||
useTabs();
|
||||
const { allRenderers, fetchRenderers } = useRenderers();
|
||||
const { allServers, fetchServers } = useMediaServers();
|
||||
|
||||
// État du drawer server
|
||||
const drawerOpen = ref(false)
|
||||
const drawerOpen = ref(false);
|
||||
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null)
|
||||
const viewRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
// Swipe right depuis le bord gauche → ouvrir drawer
|
||||
if (swipeDirection === 'right' && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0]
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
|
||||
if (touch && touch.clientX < 50) {
|
||||
drawerOpen.value = true
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Gestion de la lecture d'un item depuis le drawer
|
||||
async function handlePlayItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
}
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Play item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, true) // autoPlay = true
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de la lecture:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion de l'ajout d'un item à la queue depuis le drawer
|
||||
async function handleQueueItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
}
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Queue item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, false) // autoPlay = false
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de l\'ajout à la queue:', error)
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Nombre de servers online pour afficher dans le badge
|
||||
const onlineServersCount = computed(() => allServers.value.filter((s) => s.online).length)
|
||||
const onlineServersCount = computed(
|
||||
() => allServers.value.filter((s) => s.online).length,
|
||||
);
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
function handleDrawerOpen() {
|
||||
drawerOpen.value = true
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
// Sync route query params avec l'état des tabs
|
||||
onMounted(async () => {
|
||||
// Fetch renderers et servers au montage
|
||||
await Promise.all([
|
||||
fetchRenderers(),
|
||||
fetchServers()
|
||||
])
|
||||
await Promise.all([fetchRenderers(), fetchServers()]);
|
||||
|
||||
// Sync initial des tabs avec les renderers
|
||||
syncWithRenderers(allRenderers.value)
|
||||
syncWithRenderers(allRenderers.value);
|
||||
|
||||
// Restaurer l'onglet actif depuis l'URL
|
||||
const urlTabId = route.query.tab as string
|
||||
const urlTabId = route.query.tab as string;
|
||||
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
|
||||
switchTab(urlTabId)
|
||||
switchTab(urlTabId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Watch renderers pour sync automatique des tabs
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(newRenderers)
|
||||
syncWithRenderers(newRenderers);
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
);
|
||||
|
||||
// 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 (newTabId && typeof newTabId === "string") {
|
||||
const tab = tabs.value.find((t) => t.id === newTabId);
|
||||
if (tab && activeTabId.value !== newTabId) {
|
||||
switchTab(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
|
||||
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(','),
|
||||
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
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return null;
|
||||
|
||||
switch (tab.type) {
|
||||
case 'renderer':
|
||||
return RendererTabContent
|
||||
case 'server':
|
||||
return ServerTabContent
|
||||
case "renderer":
|
||||
return RendererTabContent;
|
||||
case "server":
|
||||
return ServerTabContent;
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Props pour le composant actif
|
||||
const currentTabProps = computed(() => {
|
||||
const tab = activeTab.value
|
||||
if (!tab || !tab.metadata) return null
|
||||
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 === "renderer" && tab.metadata.rendererId) {
|
||||
return { rendererId: tab.metadata.rendererId };
|
||||
}
|
||||
if (tab.type === 'server' && tab.metadata.serverId) {
|
||||
return { serverId: tab.metadata.serverId }
|
||||
if (tab.type === "server" && tab.metadata.serverId) {
|
||||
return { serverId: tab.metadata.serverId };
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -202,13 +163,15 @@ const currentTabProps = computed(() => {
|
||||
</main>
|
||||
|
||||
<!-- Barre d'onglets en bas -->
|
||||
<BottomTabBar :online-servers-count="onlineServersCount" @open-drawer="handleDrawerOpen" />
|
||||
<BottomTabBar
|
||||
:online-servers-count="onlineServersCount"
|
||||
@open-drawer="handleDrawerOpen"
|
||||
/>
|
||||
|
||||
<!-- Drawer servers (swipe depuis bord gauche) -->
|
||||
<ServerDrawer
|
||||
v-model="drawerOpen"
|
||||
@play-item="handlePlayItem"
|
||||
@queue-item="handleQueueItem"
|
||||
:selected-renderer-id="currentRendererId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -319,7 +282,9 @@ const currentTabProps = computed(() => {
|
||||
/* Animation de transition des onglets */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.v-enter-from {
|
||||
|
||||
@@ -588,6 +588,19 @@ impl ControlPoint {
|
||||
&self,
|
||||
renderer_id: &DeviceId,
|
||||
items: Vec<PlaybackItem>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.enqueue_items_with_mode(renderer_id, items, EnqueueMode::AppendToEnd)
|
||||
}
|
||||
|
||||
/// Enqueue items to a renderer's queue with a specific enqueue mode.
|
||||
///
|
||||
/// This is the low-level version that allows specifying the enqueue mode.
|
||||
/// User-driven operations should detach any playlist binding.
|
||||
pub fn enqueue_items_with_mode(
|
||||
&self,
|
||||
renderer_id: &DeviceId,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// User-driven mutation: detach any playlist binding
|
||||
self.detach_playlist_binding(renderer_id, "enqueue_items");
|
||||
@@ -600,7 +613,7 @@ impl ControlPoint {
|
||||
|
||||
let new_len = {
|
||||
let mut queue = renderer.get_queue_mut();
|
||||
queue.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
||||
queue.enqueue_items(items, mode)?;
|
||||
queue.upcoming_len()?
|
||||
};
|
||||
|
||||
@@ -608,6 +621,7 @@ impl ControlPoint {
|
||||
renderer = renderer_id.0.as_str(),
|
||||
added = item_count,
|
||||
queue_len = new_len,
|
||||
mode = ?mode,
|
||||
"Enqueued playback items"
|
||||
);
|
||||
|
||||
@@ -855,7 +869,7 @@ impl ControlPoint {
|
||||
})?;
|
||||
|
||||
// Use generic queue access (works for all backends)
|
||||
let Some((item, remaining)) = renderer.get_queue().peek_current()? else {
|
||||
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"play_current_from_queue: queue is empty or no current item"
|
||||
@@ -976,7 +990,7 @@ impl ControlPoint {
|
||||
}
|
||||
|
||||
// Get the next item from the queue using peek_current
|
||||
let Ok(Some((_, remaining))) = renderer.get_queue().peek_current() else {
|
||||
let Ok(Some((_, remaining))) = renderer.get_queue_mut().peek_current() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1022,7 +1036,7 @@ impl ControlPoint {
|
||||
// Set queue index
|
||||
renderer.get_queue_mut().set_index(Some(index))?;
|
||||
|
||||
let Some((item, remaining)) = renderer.get_queue().peek_current()? else {
|
||||
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
index, "play_queue_index: no item at index"
|
||||
|
||||
@@ -231,7 +231,7 @@ impl MusicRenderer {
|
||||
|
||||
/// Play the current item from the queue.
|
||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
|
||||
// Get the current item from the queue
|
||||
|
||||
@@ -1392,6 +1392,118 @@ async fn add_to_queue(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/queue/add-after - Ajouter du contenu après le morceau actuel
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/queue/add-after",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = PlayContentRequest,
|
||||
responses(
|
||||
(status = 200, description = "Contenu ajouté après le morceau actuel", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer ou serveur non trouvé", body = ErrorResponse),
|
||||
(status = 504, description = "Timeout de la commande", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn add_after_current(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<PlayContentRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let sid = DeviceId(req.server_id.clone());
|
||||
let object_id = req.object_id.clone();
|
||||
let object_id_for_log = object_id.clone();
|
||||
|
||||
// Verify renderer exists
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
|
||||
// Spawn blocking task for content loading
|
||||
let add_task = tokio::task::spawn_blocking(move || {
|
||||
// Fetch playback items from server
|
||||
let items = fetch_playback_items(&control_point, &sid, &object_id)?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Err(anyhow::anyhow!("No playable content found"));
|
||||
}
|
||||
|
||||
// Insert items after current using the new method
|
||||
control_point.enqueue_items_with_mode(
|
||||
&rid,
|
||||
items,
|
||||
crate::queue::EnqueueMode::InsertAfterCurrent,
|
||||
)?;
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Add after current command for renderer {} exceeded {:?}",
|
||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Add after current timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during add after current: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to add content after current for renderer {}: {}",
|
||||
renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to add after current: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
server = req.server_id.as_str(),
|
||||
object = object_id_for_log.as_str(),
|
||||
"Content added after current via HTTP API"
|
||||
);
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Content added after current track".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - MEDIA SERVERS
|
||||
// ============================================================================
|
||||
@@ -1703,6 +1815,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
// Queue content
|
||||
.route("/renderers/{renderer_id}/queue/play", post(play_content))
|
||||
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/add-after",
|
||||
post(add_after_current),
|
||||
)
|
||||
// Servers
|
||||
.route("/servers", get(list_servers))
|
||||
.route(
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
||||
pub enum EnqueueMode {
|
||||
/// Append new items at the end of the queue.
|
||||
AppendToEnd,
|
||||
/// Insert new items immediately after the current index
|
||||
/// Insert new items immediately after the current playing index
|
||||
/// (or at the beginning if there is no current index).
|
||||
InsertAfterCurrent,
|
||||
/// Replace the whole queue with the new items.
|
||||
@@ -161,7 +161,7 @@ pub trait QueueBackend {
|
||||
|
||||
/// Returns the current item (or the first pending item if no index is set)
|
||||
/// along with the count of remaining items.
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let QueueSnapshot {
|
||||
items,
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
//! - maintains a `current_index`,
|
||||
//! - never starts playback (transport control is handled elsewhere).
|
||||
|
||||
|
||||
use crate::{
|
||||
DeviceId, DeviceIdentity, RendererInfo,
|
||||
errors::ControlPointError,
|
||||
@@ -72,27 +71,16 @@ impl QueueBackend for InternalQueue {
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
u32::try_from(id).map_err(|_| {
|
||||
ControlPointError::QueueError(format!(
|
||||
"Position {} exceeds u32::MAX",
|
||||
id
|
||||
))
|
||||
})
|
||||
u32::try_from(id)
|
||||
.map_err(|_| ControlPointError::QueueError(format!("Position {} exceeds u32::MAX", id)))
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self.current_index {
|
||||
None => Ok(None),
|
||||
Some(i) => {
|
||||
u32::try_from(i)
|
||||
.map(Some)
|
||||
.map_err(|_| {
|
||||
ControlPointError::QueueError(format!(
|
||||
"Current index {} exceeds u32::MAX",
|
||||
i
|
||||
))
|
||||
})
|
||||
}
|
||||
Some(i) => u32::try_from(i).map(Some).map_err(|_| {
|
||||
ControlPointError::QueueError(format!("Current index {} exceeds u32::MAX", i))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,16 +130,14 @@ impl QueueBackend for InternalQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>
|
||||
) -> Result<(), ControlPointError> {
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
if items.is_empty() {
|
||||
return self.replace_queue(Vec::new(), None);
|
||||
}
|
||||
|
||||
// Récupérer l'item actuel
|
||||
let current = self.current_index
|
||||
let current = self
|
||||
.current_index
|
||||
.and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone())));
|
||||
|
||||
if let Some((_current_idx, current_uri)) = current {
|
||||
@@ -187,7 +173,8 @@ impl QueueBackend for InternalQueue {
|
||||
self.items.extend(items);
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
let insert_pos = self.current_index
|
||||
let insert_pos = self
|
||||
.current_index
|
||||
.map(|i| (i + 1).min(self.items.len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
@@ -242,7 +229,7 @@ impl QueueBackend for InternalQueue {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
if self.items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -250,7 +237,11 @@ impl QueueBackend for InternalQueue {
|
||||
let len = self.items.len();
|
||||
let (item, resolved_index) = match self.current_index {
|
||||
Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)),
|
||||
_ => (self.items.first().cloned(), None),
|
||||
_ => {
|
||||
// Si current_index est None ou invalide, initialiser à 0
|
||||
self.current_index = Some(0);
|
||||
(self.items.first().cloned(), Some(0))
|
||||
}
|
||||
};
|
||||
|
||||
let item = match item {
|
||||
|
||||
@@ -156,7 +156,7 @@ impl QueueBackend for MusicQueue {
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.peek_current(),
|
||||
MusicQueue::OpenHome(q) => q.peek_current(),
|
||||
|
||||
@@ -778,7 +778,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
}
|
||||
|
||||
/// Optimized peek_current: use primitives instead of full snapshot.
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let len = self.len()?;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
|
||||
Reference in New Issue
Block a user