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.
528 lines
13 KiB
Vue
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>
|