Files
pmomusic/pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Eric Coissac ea5328450b Implémentation de la détection de flux continu et gestion de la barre de progression
Cette mise à jour implémente la détection des flux continus (radio, streaming) et améliore la gestion de la barre de progression pour ces flux. Les changements incluent : 
- Ajout d'une méthode predicat `is_playing_a_stream()` au niveau de `MusicRenderer`
- Développement d'une fonction utilitaire `is_continuous_stream_url()` pour analyser les headers HTTP
- Mise à jour des backends (UPnP, OpenHome, LinkPlay, ArylicTcp, Chromecast) pour détecter les flux continus
- Ajout d'un flag `continuous_stream` dans chaque backend
- Export du module de détection de flux
- Mise à jour de l'API REST et des événements SSE pour transmettre l'état du flux
- Correction de la gestion de la position de lecture pour les flux continus

Les flux continus sont maintenant correctement détectés via une analyse HTTP des headers, permettant une gestion appropriée de la barre de progression dans l'interface web.
2026-01-31 10:33:06 +01:00

528 lines
13 KiB
Vue

<template>
<div class="timer-control">
<button
class="timer-button"
:class="{ active: timerState?.active }"
@click.stop="toggleTimerDialog"
:title="buttonTitle"
ref="buttonRef"
>
<Clock :size="36" />
<span
v-if="timerState?.active && remainingMinutes !== null"
class="timer-badge"
>
{{ remainingMinutes }}
</span>
</button>
<!-- Backdrop et Dialog (téléportés au body) -->
<Teleport to="body">
<div
v-if="showDialog"
class="timer-backdrop"
@click="closeDialog"
></div>
<div v-if="showDialog" class="timer-dialog" @click.stop>
<div class="timer-dialog-content">
<div class="timer-header">
<h3>Sleep Timer</h3>
<button class="close-button" @click="closeDialog">
<X :size="18" />
</button>
</div>
<div class="timer-body">
<!-- Affichage compact du temps restant -->
<div v-if="timerState?.active" class="time-display">
{{ formatTime(remainingSeconds) }}
</div>
<!-- Slider compact -->
<div class="slider-section">
<div class="slider-value">
{{ sliderValue }} min
</div>
<input
type="range"
min="0"
max="120"
step="5"
v-model.number="sliderValue"
class="timer-slider"
@change="handleSliderChange"
/>
<div class="slider-marks">
<span>0</span>
<span>60</span>
<span>120</span>
</div>
</div>
<!-- Bouton annuler (seulement si actif) -->
<button
v-if="timerState?.active"
class="btn-cancel"
@click="handleCancel"
:disabled="isLoading"
>
Annuler le timer
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { Clock, X } from "lucide-vue-next";
import { api } from "@/services/pmocontrol/api";
import { sse } from "@/services/pmocontrol/sse";
import type {
SleepTimerState,
RendererEventPayload,
} from "@/services/pmocontrol/types";
const props = defineProps<{
rendererId: string;
}>();
const showDialog = ref(false);
const sliderValue = ref(0);
const timerState = ref<SleepTimerState | null>(null);
const isLoading = ref(false);
const buttonRef = ref<HTMLElement | null>(null);
const localRemainingSeconds = ref<number | null>(null);
let countdownInterval: number | null = null;
const remainingSeconds = computed(
() =>
localRemainingSeconds.value ??
timerState.value?.remaining_seconds ??
null,
);
const remainingMinutes = computed(() => {
if (remainingSeconds.value === null) return null;
return Math.ceil(remainingSeconds.value / 60);
});
const buttonTitle = computed(() => {
if (timerState.value?.active && remainingMinutes.value !== null) {
return `Sleep timer: ${remainingMinutes.value} min restantes`;
}
return "Sleep timer";
});
function formatTime(seconds: number | null): string {
if (seconds === null) return "--:--";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function startCountdown() {
if (countdownInterval !== null) {
clearInterval(countdownInterval);
}
if (timerState.value?.active && timerState.value.remaining_seconds) {
localRemainingSeconds.value = timerState.value.remaining_seconds;
countdownInterval = window.setInterval(() => {
if (
localRemainingSeconds.value !== null &&
localRemainingSeconds.value > 0
) {
localRemainingSeconds.value--;
} else {
stopCountdown();
}
}, 1000);
}
}
function stopCountdown() {
if (countdownInterval !== null) {
clearInterval(countdownInterval);
countdownInterval = null;
}
localRemainingSeconds.value = null;
}
async function fetchTimerState() {
try {
const state = await api.getSleepTimer(props.rendererId);
timerState.value = state;
if (state.active && state.duration_seconds) {
sliderValue.value = Math.round(state.duration_seconds / 60);
startCountdown();
} else {
stopCountdown();
}
} catch (error) {
console.error("Erreur lors de la récupération du timer:", error);
timerState.value = null;
stopCountdown();
}
}
// Le slider modifie le timer en temps réel
async function handleSliderChange() {
if (sliderValue.value === 0) {
// Si on met à 0, on annule
await handleCancel();
return;
}
isLoading.value = true;
try {
const durationSeconds = sliderValue.value * 60;
if (timerState.value?.active) {
await api.updateSleepTimer(props.rendererId, durationSeconds);
} else {
await api.startSleepTimer(props.rendererId, durationSeconds);
}
await fetchTimerState();
} catch (error) {
console.error("Erreur lors de la configuration du timer:", error);
} finally {
isLoading.value = false;
}
}
async function handleCancel() {
isLoading.value = true;
try {
await api.cancelSleepTimer(props.rendererId);
timerState.value = {
active: false,
duration_seconds: 0,
remaining_seconds: null,
};
sliderValue.value = 0;
closeDialog();
} catch (error) {
console.error("Erreur lors de l'annulation du timer:", error);
} finally {
isLoading.value = false;
}
}
function toggleTimerDialog() {
showDialog.value = !showDialog.value;
}
function closeDialog() {
showDialog.value = false;
}
let sseUnsubscribe: (() => void) | null = null;
function handleTimerEvent(event: RendererEventPayload) {
if (event.renderer_id !== props.rendererId) return;
switch (event.type) {
case "timer_started":
case "timer_updated":
timerState.value = {
active: true,
duration_seconds: event.duration_seconds,
remaining_seconds: event.remaining_seconds,
};
startCountdown();
break;
case "timer_tick":
if (timerState.value?.active) {
timerState.value = {
...timerState.value,
remaining_seconds: event.remaining_seconds,
};
// Resynchroniser le countdown local
localRemainingSeconds.value = event.remaining_seconds;
}
break;
case "timer_expired":
case "timer_cancelled":
timerState.value = {
active: false,
duration_seconds: 0,
remaining_seconds: null,
};
sliderValue.value = 0;
stopCountdown();
break;
}
}
// Surveiller les changements de renderer
watch(
() => props.rendererId,
() => {
// Réinitialiser l'état quand on change de renderer
stopCountdown();
showDialog.value = false;
sliderValue.value = 0;
timerState.value = null;
// Charger l'état du nouveau renderer
fetchTimerState();
},
);
onMounted(() => {
fetchTimerState();
sseUnsubscribe = sse.onRendererEvent(handleTimerEvent);
});
onUnmounted(() => {
if (sseUnsubscribe) {
sseUnsubscribe();
}
stopCountdown();
});
</script>
<style scoped>
.timer-control {
position: relative;
}
.timer-button {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
cursor: pointer;
transition: all 0.3s ease;
color: var(--color-text);
}
.timer-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.timer-button:active {
transform: scale(0.95);
}
.timer-button.active {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.4);
}
@media (prefers-color-scheme: dark) {
.timer-button {
background: rgba(255, 255, 255, 0.15);
}
.timer-button:hover {
background: rgba(255, 255, 255, 0.25);
}
}
.timer-badge {
position: absolute;
top: 2px;
right: 2px;
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 11px;
font-weight: 700;
color: white;
background: rgba(34, 197, 94, 0.9);
border: 2px solid var(--color-bg);
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.timer-backdrop {
position: fixed;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
margin: 0;
padding: 0;
}
.timer-dialog {
position: fixed;
bottom: 60px;
right: 20px;
z-index: 1000;
}
.timer-dialog-content {
background: var(--background-secondary, #1f2937);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
width: 280px;
}
.timer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
.timer-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #ffffff);
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: var(--glass-background, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #ffffff);
}
.timer-body {
padding: 1rem;
}
.time-display {
text-align: center;
font-size: 28px;
font-weight: 600;
color: var(--status-playing, #22c55e);
margin-bottom: 0.75rem;
font-variant-numeric: tabular-nums;
}
.slider-section {
margin-bottom: 0.75rem;
}
.slider-value {
text-align: center;
font-size: 14px;
font-weight: 500;
color: var(--text-primary, #ffffff);
margin-bottom: 0.5rem;
}
.timer-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--glass-background, rgba(255, 255, 255, 0.1));
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.timer-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--status-playing, #22c55e);
cursor: pointer;
transition: all 0.2s ease;
}
.timer-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.timer-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--status-playing, #22c55e);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.timer-slider::-moz-range-thumb:hover {
transform: scale(1.2);
}
.slider-marks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 10px;
color: var(--text-secondary, #9ca3af);
}
.btn-cancel {
width: 100%;
padding: 0.5rem;
border-radius: 6px;
border: none;
font-size: 13px;
font-weight: 500;
background: var(--glass-background, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-cancel:hover:not(:disabled) {
background: var(--glass-background-hover, rgba(255, 255, 255, 0.15));
}
.btn-cancel:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.timer-dialog-content {
width: 260px;
}
.timer-button {
width: 36px;
height: 36px;
}
}
</style>