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

@@ -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 */

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,
}
};
}

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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);