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:
2026-01-10 15:08:44 +01:00
parent 56e1f4c0fb
commit c745179200
9 changed files with 982 additions and 512 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +1,221 @@
<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)
} catch (error) {
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
try {
await resumeOrPlayFromQueue(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handlePause() {
try {
await pause(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
try {
await pause(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handleStop() {
try {
await stop(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
try {
await stop(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
async function handleNext() {
try {
await next(props.rendererId)
} catch (error) {
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
}
try {
await next(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
</script>
<template>
<div class="transport-controls">
<button
class="btn btn-icon btn-primary"
:disabled="isPlaying"
@click="handlePlay"
title="Lecture"
>
<Play :size="20" />
</button>
<div class="transport-controls">
<button
class="btn btn-icon btn-primary"
:disabled="isPlaying"
@click="handlePlay"
title="Lecture"
>
<Play :size="20" />
</button>
<button
class="btn btn-icon"
:disabled="isPaused || isStopped"
@click="handlePause"
title="Pause"
>
<Pause :size="20" />
</button>
<button
class="btn btn-icon"
:disabled="isPaused || isStopped"
@click="handlePause"
title="Pause"
>
<Pause :size="20" />
</button>
<button
class="btn btn-icon"
:disabled="isStopped"
@click="handleStop"
title="Stop"
>
<Square :size="20" />
</button>
<button
class="btn btn-icon"
:disabled="isStopped"
@click="handleStop"
title="Stop"
>
<Square :size="20" />
</button>
<button
class="btn btn-icon"
:disabled="!state?.queue_len"
@click="handleNext"
title="Suivant"
>
<SkipForward :size="20" />
</button>
</div>
<button
class="btn btn-icon"
:disabled="!state?.queue_len"
@click="handleNext"
title="Suivant"
>
<SkipForward :size="20" />
</button>
</div>
</template>
<style scoped>
.transport-controls {
display: flex;
gap: var(--spacing-md);
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
border-radius: 24px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
display: flex;
gap: var(--spacing-md);
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
border-radius: 24px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
0 8px 32px 0 rgba(31, 38, 135, 0.15),
inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
}
@media (prefers-color-scheme: dark) {
.transport-controls {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.12);
}
.transport-controls {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.12);
}
}
/* Boutons avec effet glass */
.transport-controls .btn-icon {
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px) saturate(150%);
-webkit-backdrop-filter: blur(10px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateZ(0);
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px) saturate(150%);
-webkit-backdrop-filter: blur(10px) saturate(150%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateZ(0);
}
.transport-controls .btn-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}
.transport-controls .btn-icon:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.transport-controls .btn-icon:disabled {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
opacity: 0.5;
cursor: not-allowed;
}
/* Bouton primary (play) avec accent vert */
.transport-controls .btn-primary {
background: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.5);
background: rgba(34, 197, 94, 0.3);
border-color: rgba(34, 197, 94, 0.5);
}
.transport-controls .btn-primary:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.4);
border-color: rgba(34, 197, 94, 0.6);
background: rgba(34, 197, 94, 0.4);
border-color: rgba(34, 197, 94, 0.6);
}
@media (prefers-color-scheme: dark) {
.transport-controls .btn-icon {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.transport-controls .btn-icon {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.transport-controls .btn-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.transport-controls .btn-icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
}
/* Fallback pour navigateurs sans backdrop-filter */
@supports not (backdrop-filter: blur(20px)) {
.transport-controls {
background: rgba(255, 255, 255, 0.95);
}
.transport-controls .btn-icon {
background: rgba(255, 255, 255, 0.9);
}
@media (prefers-color-scheme: dark) {
.transport-controls {
background: rgba(0, 0, 0, 0.95);
background: rgba(255, 255, 255, 0.95);
}
.transport-controls .btn-icon {
background: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.9);
}
@media (prefers-color-scheme: dark) {
.transport-controls {
background: rgba(0, 0, 0, 0.95);
}
.transport-controls .btn-icon {
background: rgba(255, 255, 255, 0.15);
}
}
}
}
/* Mode kiosque - compactage pour petites hauteurs (800x600) */
@media (max-height: 700px) and (orientation: landscape) {
.transport-controls {
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
}
.transport-controls {
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
}
.transport-controls .btn-icon {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
.transport-controls .btn-icon {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
}
</style>

View File

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