Implémentation du seek audio avec support des Range requests
Ajout de la fonctionnalité de seek audio dans les renderers avec support des Range requests HTTP pour permettre le seek pendant le téléchargement.
- Ajout de la route /control/renderers/{renderer_id}/seek pour le seek par secondes
- Implémentation du support des Range requests dans le serveur de fichiers (pmocache)
- Mise à jour des dépendances avec http-range-header, tower, tower-http
- Amélioration de l'interface utilisateur avec une barre de progression interactive dans CurrentTrack.vue
- Support du seek pendant le téléchargement avec streaming progressif
- Gestion des erreurs et timeouts pour les commandes de seek
This commit is contained in:
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -2084,6 +2084,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -3677,6 +3683,8 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
]
|
||||
@@ -5844,14 +5852,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,78 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, ref } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import { Music, X } from 'lucide-vue-next'
|
||||
import { computed, toRef, ref } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import { Music, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const metadata = computed(() => state.value?.current_track)
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
const seekTargetMs = ref<number | null>(null);
|
||||
|
||||
// État pour l'overlay de la cover
|
||||
const showCoverOverlay = ref(false)
|
||||
const showMetadata = ref(false)
|
||||
const showCoverOverlay = ref(false);
|
||||
const showMetadata = ref(false);
|
||||
|
||||
function openCoverOverlay() {
|
||||
if (hasCover.value) {
|
||||
showCoverOverlay.value = true
|
||||
showMetadata.value = false // Reset metadata visibility when opening
|
||||
showCoverOverlay.value = true;
|
||||
showMetadata.value = false; // Reset metadata visibility when opening
|
||||
}
|
||||
}
|
||||
|
||||
function closeCoverOverlay() {
|
||||
showCoverOverlay.value = false
|
||||
showMetadata.value = false
|
||||
showCoverOverlay.value = false;
|
||||
showMetadata.value = false;
|
||||
}
|
||||
|
||||
// Détecter le clic sur la partie basse de l'image
|
||||
function handleOverlayContentClick(event: MouseEvent) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const clickY = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const clickY = event.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
// Si le clic est dans le tiers inférieur, toggle les métadonnées
|
||||
if (clickY > height * 0.66) {
|
||||
showMetadata.value = !showMetadata.value
|
||||
showMetadata.value = !showMetadata.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul du pourcentage de progression
|
||||
const progressPercent = computed(() => {
|
||||
const position = state.value?.position_ms
|
||||
const duration = state.value?.duration_ms
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100
|
||||
// Si on est en train de drag, utiliser la position du drag
|
||||
if (isDragging.value) {
|
||||
return dragProgress.value;
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Si on a une cible de seek active
|
||||
if (seekTargetMs.value !== null) {
|
||||
const currentPosition = state.value?.position_ms || 0;
|
||||
|
||||
// Si la position actuelle est proche de la cible (tolérance de 2 secondes)
|
||||
if (Math.abs(currentPosition - seekTargetMs.value) < 2000) {
|
||||
// On peut libérer le contrôle
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} else {
|
||||
// Sinon, continuer à afficher la position cible
|
||||
return dragProgress.value;
|
||||
}
|
||||
}
|
||||
|
||||
const position = state.value?.position_ms;
|
||||
const duration = state.value?.duration_ms;
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Formater la durée en MM:SS
|
||||
function formatTime(ms: number | null | undefined): string {
|
||||
if (!ms) return '--:--'
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
if (!ms) return "--:--";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms));
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri);
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
}
|
||||
|
||||
// Calculer le pourcentage à partir d'une position X dans la barre
|
||||
function calculateProgressFromX(
|
||||
clientX: number,
|
||||
progressBar: HTMLElement,
|
||||
): number {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
return (x / rect.width) * 100;
|
||||
}
|
||||
|
||||
// Gestion du drag sur la progress bar
|
||||
function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
const duration = state.value?.duration_ms;
|
||||
if (!duration || duration === 0 || isSeeking.value) return;
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement;
|
||||
isDragging.value = true;
|
||||
dragProgress.value = calculateProgressFromX(event.clientX, progressBar);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
dragProgress.value = calculateProgressFromX(e.clientX, progressBar);
|
||||
};
|
||||
|
||||
const handleMouseUp = async (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const finalPercent = calculateProgressFromX(e.clientX, progressBar);
|
||||
const newPositionMs = (finalPercent / 100) * duration;
|
||||
const newPositionSeconds = Math.floor(newPositionMs / 1000);
|
||||
|
||||
// Garder dragProgress à jour et définir la cible
|
||||
dragProgress.value = finalPercent;
|
||||
seekTargetMs.value = newPositionMs;
|
||||
|
||||
// D'abord arrêter le drag pour éviter les mouvements pendant le seek
|
||||
isDragging.value = false;
|
||||
|
||||
try {
|
||||
isSeeking.value = true;
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} finally {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
// Gestion du seek sur la progress bar (clic simple)
|
||||
async function handleProgressBarClick(event: MouseEvent) {
|
||||
// Ne rien faire si on vient de drag (géré par mouseup)
|
||||
if (isDragging.value) return;
|
||||
|
||||
const duration = state.value?.duration_ms;
|
||||
if (!duration || duration === 0 || isSeeking.value) return;
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement;
|
||||
const percent = calculateProgressFromX(event.clientX, progressBar);
|
||||
const newPositionMs = (percent / 100) * duration;
|
||||
const newPositionSeconds = Math.floor(newPositionMs / 1000);
|
||||
|
||||
// Définir la cible et la position visuelle
|
||||
dragProgress.value = percent;
|
||||
seekTargetMs.value = newPositionMs;
|
||||
|
||||
try {
|
||||
isSeeking.value = true;
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} finally {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-track">
|
||||
<!-- Cover Art -->
|
||||
<div class="cover-container" :class="{ clickable: hasCover }" @click="openCoverOverlay">
|
||||
<div
|
||||
class="cover-container"
|
||||
:class="{ clickable: hasCover }"
|
||||
@click="openCoverOverlay"
|
||||
>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
@@ -88,18 +211,25 @@ function handleImageError(event: Event) {
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="metadata">
|
||||
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||
<h2 class="title">{{ metadata?.title || "Aucun titre" }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || "Artiste inconnu" }}</p>
|
||||
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-bar"
|
||||
@click="handleProgressBarClick"
|
||||
@mousedown="handleProgressBarMouseDown"
|
||||
:class="{ seeking: isSeeking, dragging: isDragging }"
|
||||
>
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
>
|
||||
<div class="progress-bar-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ currentTime }}</span>
|
||||
@@ -110,9 +240,20 @@ function handleImageError(event: Event) {
|
||||
<!-- Overlay cover en grand avec effet glassmorphism -->
|
||||
<Teleport to="body">
|
||||
<Transition name="cover-overlay">
|
||||
<div v-if="showCoverOverlay" class="cover-overlay" @click="closeCoverOverlay">
|
||||
<div class="cover-overlay-content" @click.stop="handleOverlayContentClick">
|
||||
<button class="cover-overlay-close" @click="closeCoverOverlay" title="Fermer">
|
||||
<div
|
||||
v-if="showCoverOverlay"
|
||||
class="cover-overlay"
|
||||
@click="closeCoverOverlay"
|
||||
>
|
||||
<div
|
||||
class="cover-overlay-content"
|
||||
@click.stop="handleOverlayContentClick"
|
||||
>
|
||||
<button
|
||||
class="cover-overlay-close"
|
||||
@click="closeCoverOverlay"
|
||||
title="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
<img
|
||||
@@ -122,10 +263,15 @@ function handleImageError(event: Event) {
|
||||
class="cover-overlay-image"
|
||||
/>
|
||||
<Transition name="metadata-fade">
|
||||
<div v-if="metadata && showMetadata" class="cover-overlay-metadata">
|
||||
<div
|
||||
v-if="metadata && showMetadata"
|
||||
class="cover-overlay-metadata"
|
||||
>
|
||||
<h2>{{ metadata.title }}</h2>
|
||||
<p class="artist">{{ metadata.artist }}</p>
|
||||
<p v-if="metadata.album" class="album">{{ metadata.album }}</p>
|
||||
<p v-if="metadata.album" class="album">
|
||||
{{ metadata.album }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -151,7 +297,9 @@ function handleImageError(event: Event) {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-container.clickable {
|
||||
@@ -210,6 +358,98 @@ function handleImageError(event: Event) {
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.25) 50%,
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-bar:hover {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar.dragging {
|
||||
cursor: grabbing;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar.seeking {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-bar.dragging .progress-bar-fill {
|
||||
transition: width 0s;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
|
||||
}
|
||||
|
||||
.progress-bar-thumb {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border: 3px solid rgba(0, 0, 0, 0.8);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.7),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-bar.dragging .progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.3);
|
||||
cursor: grabbing;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.8),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -346,7 +586,7 @@ function handleImageError(event: Event) {
|
||||
}
|
||||
|
||||
.cover-overlay-close:active {
|
||||
transform: scale(1.0) rotate(90deg);
|
||||
transform: scale(1) rotate(90deg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -1,50 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||
import { computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Play, Pause, Square, SkipForward } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||
const isPlaying = computed(() => state.value?.transport_state === "PLAYING");
|
||||
const isPaused = computed(() => state.value?.transport_state === "PAUSED");
|
||||
const isStopped = computed(
|
||||
() =>
|
||||
state.value?.transport_state === "STOPPED" ||
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId)
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -168,6 +168,20 @@ class PMOControlAPI {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek à une position spécifique (en secondes)
|
||||
* POST /api/control/renderers/{id}/seek
|
||||
*/
|
||||
async seekTo(id: string, seconds: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/seek`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ seconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saute à un index spécifique dans la queue
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
|
||||
@@ -38,6 +38,8 @@ utoipa = { version = "5.3", optional = true }
|
||||
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
tower-http = { version = "0.6", features = ["fs"], optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
@@ -49,5 +51,5 @@ tempfile = "3"
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoserver = ["dep:axum", "dep:tower", "dep:tower-http"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
|
||||
@@ -90,10 +90,11 @@ async fn get_file<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path(pk): Path<String>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// Utiliser le param par défaut
|
||||
let param = C::default_param();
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
|
||||
@@ -106,8 +107,9 @@ async fn get_file_with_param<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path((pk, param)): Path<(String, String)>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -116,6 +118,7 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
let qualifier = param.to_string();
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -129,7 +132,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
// la lecture pendant le téléchargement tout en préservant la durée/position
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
if !download.finished().await {
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -140,9 +144,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
// ROUTE 2 : Fichier complètement téléchargé
|
||||
// Utilise l'ancien système éprouvé qui garantit un passage correct
|
||||
// de toutes les informations (Content-Length automatique, etc.)
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Utilise ServeFile avec support des Range requests pour le seek
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -166,6 +169,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
tracing::info!("Lazy download triggered for pk: {}", lazy_pk);
|
||||
|
||||
@@ -176,7 +180,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk,
|
||||
real_pk
|
||||
);
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type).await;
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
// 2. Télécharger en résolvant l'URL via la DB ou un provider
|
||||
@@ -196,7 +200,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await;
|
||||
|
||||
// 5. Servir directement le fichier téléchargé
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type).await
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type, request).await
|
||||
}
|
||||
|
||||
/// Fonction utilitaire pour servir un fichier avec streaming progressif
|
||||
@@ -211,10 +215,11 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
param_generator: Option<ParamGenerator<C>>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// LAZY PK SUPPORT: Détecter si c'est un lazy PK
|
||||
if crate::cache::is_lazy_pk(pk) {
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type).await;
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -246,8 +251,9 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
// Le fichier est en cours de téléchargement
|
||||
if !download.finished().await {
|
||||
// Streaming progressif
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
// Streaming progressif avec support Range
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -257,8 +263,8 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier terminé ou pas de download en cours, servir normalement
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -268,13 +274,17 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
/// Stream un fichier en cours de téléchargement de manière progressive
|
||||
///
|
||||
/// Supporte les Range requests pour permettre le seek même pendant le téléchargement
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn stream_file_progressive(
|
||||
file_path: std::path::PathBuf,
|
||||
download: Arc<crate::download::Download>,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
|
||||
const MIN_SIZE_TO_START: u64 = 64 * 1024;
|
||||
@@ -291,8 +301,14 @@ async fn stream_file_progressive(
|
||||
return (StatusCode::NOT_FOUND, "File not available").into_response();
|
||||
}
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// Vérifier s'il y a un header Range
|
||||
let range_header = request.headers().get(header::RANGE);
|
||||
|
||||
// Ouvrir le fichier en lecture
|
||||
let file = match tokio::fs::File::open(&file_path).await {
|
||||
let mut file = match tokio::fs::File::open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("Error opening file {:?}: {}", file_path, e);
|
||||
@@ -300,24 +316,65 @@ async fn stream_file_progressive(
|
||||
}
|
||||
};
|
||||
|
||||
// Créer un stream à partir du fichier
|
||||
// Parser le Range header si présent
|
||||
if let Some(range_value) = range_header {
|
||||
if let Ok(range_str) = range_value.to_str() {
|
||||
// Format: "bytes=start-end" ou "bytes=start-"
|
||||
if let Some(range_spec) = range_str.strip_prefix("bytes=") {
|
||||
if let Some((start_str, _)) = range_spec.split_once('-') {
|
||||
if let Ok(start) = start_str.parse::<u64>() {
|
||||
// Seek vers la position demandée
|
||||
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
|
||||
warn!("Error seeking to position {}: {}", start, e);
|
||||
return (StatusCode::RANGE_NOT_SATISFIABLE, "Invalid range")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Créer un stream à partir de la position
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
// Calculer la range
|
||||
let end = expected_size.map(|s| s - 1).unwrap_or(start + 1_000_000);
|
||||
let content_length = expected_size.map(|s| s - start);
|
||||
|
||||
// Retourner une réponse 206 Partial Content
|
||||
let mut response_builder = axum::http::Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(total_size) = expected_size {
|
||||
response_builder = response_builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, total_size),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(length) = content_length {
|
||||
response_builder =
|
||||
response_builder.header(header::CONTENT_LENGTH, length);
|
||||
}
|
||||
|
||||
return response_builder.body(body).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas de Range header : retourner le fichier complet
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Construire la réponse avec Content-Length si connu
|
||||
let mut response = axum::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type);
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(size) = expected_size {
|
||||
// Si on connaît la taille finale, l'envoyer au renderer
|
||||
// pour qu'il puisse calculer la durée et afficher la position
|
||||
response = response.header(header::CONTENT_LENGTH, size);
|
||||
} else {
|
||||
// Sinon, utiliser chunked encoding
|
||||
response = response.header(header::TRANSFER_ENCODING, "chunked");
|
||||
}
|
||||
|
||||
@@ -325,21 +382,45 @@ async fn stream_file_progressive(
|
||||
}
|
||||
|
||||
/// Sert un fichier complet déjà téléchargé
|
||||
///
|
||||
/// Utilise `tower_http::services::ServeFile` pour bénéficier automatiquement de :
|
||||
/// - Support des HTTP Range requests (seek)
|
||||
/// - Header `Accept-Ranges: bytes`
|
||||
/// - Réponses `206 Partial Content`
|
||||
/// - Streaming efficace sans chargement complet en RAM
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn serve_complete_file(
|
||||
file_path: std::path::PathBuf,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tower::ServiceExt; // Pour oneshot()
|
||||
use tower_http::services::ServeFile;
|
||||
|
||||
if !file_path.exists() {
|
||||
warn!("File not found: {:?}", file_path);
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
|
||||
// Utiliser ServeFile avec support automatique des Range requests
|
||||
// ServeFile traite automatiquement les headers Range: dans la requête
|
||||
let serve_file = ServeFile::new(file_path);
|
||||
|
||||
match serve_file.oneshot(request).await {
|
||||
Ok(response) => {
|
||||
// Convertir la réponse ServeFile en Response<Body> avec le bon Content-Type
|
||||
let (mut parts, body) = response.into_parts();
|
||||
parts
|
||||
.headers
|
||||
.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
||||
|
||||
// Convertir ServeFileSystemResponseBody en Body
|
||||
axum::http::Response::from_parts(parts, Body::new(body))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error reading file {:?}: {}", file_path, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
|
||||
warn!("Error serving file: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,16 @@ impl MusicRenderer {
|
||||
.seek_rel_time(hhmmss)
|
||||
}
|
||||
|
||||
/// Seek to a specific position in seconds
|
||||
pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> {
|
||||
// Convert seconds to HH:MM:SS format
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs);
|
||||
self.seek_rel_time(&hhmmss)
|
||||
}
|
||||
|
||||
/// Volume control: get current volume
|
||||
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
self.backend
|
||||
|
||||
@@ -266,6 +266,14 @@ pub struct SeekQueueRequest {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Requête pour seek à une position spécifique (en secondes)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct SeekRequest {
|
||||
/// Position en secondes
|
||||
pub seconds: u32,
|
||||
}
|
||||
|
||||
/// Requête pour transférer une queue d'un renderer vers un autre
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
@@ -412,6 +420,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
crate::pmoserver_ext::stop_renderer,
|
||||
crate::pmoserver_ext::resume_renderer,
|
||||
crate::pmoserver_ext::next_renderer,
|
||||
crate::pmoserver_ext::seek_renderer,
|
||||
crate::pmoserver_ext::seek_queue_index,
|
||||
crate::pmoserver_ext::set_renderer_volume,
|
||||
crate::pmoserver_ext::volume_up_renderer,
|
||||
@@ -445,6 +454,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SeekQueueRequest,
|
||||
SeekRequest,
|
||||
TransferQueueRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||
SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::queue::PlaybackItem;
|
||||
@@ -718,6 +718,88 @@ async fn seek_queue_index(
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/seek - Seek à une position spécifique (en secondes)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/control/renderers/{renderer_id}/seek",
|
||||
tag = "control",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = SeekRequest,
|
||||
responses(
|
||||
(status = 200, description = "Seek effectué avec succès", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 504, description = "Timeout lors du seek", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur interne", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn seek_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(payload): Json<SeekRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let seconds = payload.seconds;
|
||||
let seek_task = tokio::task::spawn_blocking(move || renderer.seek(seconds));
|
||||
|
||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Seek command for renderer {} exceeded {:?}",
|
||||
renderer_id, TRANSPORT_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Seek command timed out after {}s",
|
||||
TRANSPORT_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during seek: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to seek to {} seconds for renderer {}: {}",
|
||||
seconds, renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to seek to {} seconds: {}", seconds, e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Seeked to {} seconds", seconds),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
@@ -1882,6 +1964,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
|
||||
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
|
||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||
.route("/renderers/{renderer_id}/seek", post(seek_renderer))
|
||||
// Queue control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/seek",
|
||||
|
||||
Reference in New Issue
Block a user