Merge pull request 'push-mlzoossntumr' (#48) from push-mlzoossntumr into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m51s
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
<template>
|
||||||
|
<div class="timer-control">
|
||||||
|
<button
|
||||||
|
class="timer-button"
|
||||||
|
:class="{ active: timerState?.active }"
|
||||||
|
@click.stop="toggleTimerDialog"
|
||||||
|
:title="buttonTitle"
|
||||||
|
ref="buttonRef"
|
||||||
|
>
|
||||||
|
<Clock :size="24" />
|
||||||
|
<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>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { Server, Music2 } from "lucide-vue-next";
|
import { Server, Music2 } from "lucide-vue-next";
|
||||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||||
|
import TimerControl from "@/components/pmocontrol/TimerControl.vue";
|
||||||
import type {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
@@ -112,6 +113,11 @@ function handleRendererDrawerClick() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sleep Timer (si un renderer est actif) -->
|
||||||
|
<div v-if="activeRenderer" class="timer-section">
|
||||||
|
<TimerControl :renderer-id="activeRenderer.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bouton pour ouvrir le drawer des renderers (droite) -->
|
<!-- Bouton pour ouvrir le drawer des renderers (droite) -->
|
||||||
<button
|
<button
|
||||||
class="drawer-button renderer-drawer-button"
|
class="drawer-button renderer-drawer-button"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
VolumeSetRequest,
|
VolumeSetRequest,
|
||||||
AttachPlaylistRequest,
|
AttachPlaylistRequest,
|
||||||
PlayContentRequest,
|
PlayContentRequest,
|
||||||
|
SleepTimerState,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -409,6 +410,67 @@ class PMOControlAPI {
|
|||||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SLEEP TIMER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère l'état du sleep timer
|
||||||
|
* GET /api/control/renderers/{rendererId}/timer
|
||||||
|
*/
|
||||||
|
async getSleepTimer(rendererId: string): Promise<SleepTimerState> {
|
||||||
|
return this.request<SleepTimerState>(
|
||||||
|
`/renderers/${encodeURIComponent(rendererId)}/timer`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre le sleep timer
|
||||||
|
* POST /api/control/renderers/{rendererId}/timer/start
|
||||||
|
*/
|
||||||
|
async startSleepTimer(
|
||||||
|
rendererId: string,
|
||||||
|
durationSeconds: number,
|
||||||
|
): Promise<SleepTimerState> {
|
||||||
|
return this.request<SleepTimerState>(
|
||||||
|
`/renderers/${encodeURIComponent(rendererId)}/timer/start`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le sleep timer (modifie la durée)
|
||||||
|
* POST /api/control/renderers/{rendererId}/timer/update
|
||||||
|
*/
|
||||||
|
async updateSleepTimer(
|
||||||
|
rendererId: string,
|
||||||
|
durationSeconds: number,
|
||||||
|
): Promise<SleepTimerState> {
|
||||||
|
return this.request<SleepTimerState>(
|
||||||
|
`/renderers/${encodeURIComponent(rendererId)}/timer/update`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annule le sleep timer
|
||||||
|
* POST /api/control/renderers/{rendererId}/timer/cancel
|
||||||
|
*/
|
||||||
|
async cancelSleepTimer(rendererId: string): Promise<SuccessResponse> {
|
||||||
|
return this.request<SuccessResponse>(
|
||||||
|
`/renderers/${encodeURIComponent(rendererId)}/timer/cancel`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton
|
// Export singleton
|
||||||
|
|||||||
@@ -200,6 +200,36 @@ export type RendererEventPayload =
|
|||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "timer_started";
|
||||||
|
renderer_id: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
remaining_seconds: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "timer_updated";
|
||||||
|
renderer_id: string;
|
||||||
|
duration_seconds: number;
|
||||||
|
remaining_seconds: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "timer_tick";
|
||||||
|
renderer_id: string;
|
||||||
|
remaining_seconds: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "timer_expired";
|
||||||
|
renderer_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "timer_cancelled";
|
||||||
|
renderer_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "online";
|
type: "online";
|
||||||
renderer_id: string;
|
renderer_id: string;
|
||||||
@@ -254,3 +284,17 @@ export interface PositionInfo {
|
|||||||
rel_time: string | null; // Format HH:MM:SS
|
rel_time: string | null; // Format HH:MM:SS
|
||||||
track_duration: string | null; // Format HH:MM:SS
|
track_duration: string | null; // Format HH:MM:SS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SLEEP TIMER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SleepTimerState {
|
||||||
|
active: boolean;
|
||||||
|
duration_seconds: number;
|
||||||
|
remaining_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SleepTimerRequest {
|
||||||
|
duration_seconds: number; // 0-7200 (0-2 heures)
|
||||||
|
}
|
||||||
|
|||||||
@@ -494,6 +494,92 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Thread de surveillance des sleep timers
|
||||||
|
// Vérifie toutes les secondes les timers actifs et émet des événements
|
||||||
|
let registry_for_timer = Arc::clone(®istry);
|
||||||
|
let event_bus_for_timer = event_bus.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Track last emitted tick for each renderer to avoid spamming events
|
||||||
|
let mut last_tick: HashMap<DeviceId, u32> = HashMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Get all renderers with active timers
|
||||||
|
let renderers = {
|
||||||
|
let reg = registry_for_timer.read().unwrap();
|
||||||
|
match reg.list_renderers() {
|
||||||
|
Ok(renderers) => renderers,
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "Failed to list renderers in timer watchdog");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for renderer in &renderers {
|
||||||
|
// Skip renderers without active timers
|
||||||
|
if !renderer.is_sleep_timer_active() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderer_id = renderer.id();
|
||||||
|
let (is_active, duration, remaining) = renderer.sleep_timer_state();
|
||||||
|
|
||||||
|
if !is_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining_seconds = remaining.unwrap_or(0);
|
||||||
|
|
||||||
|
// Check if timer has expired
|
||||||
|
if renderer.is_sleep_timer_expired() {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"Sleep timer expired, stopping playback"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop playback
|
||||||
|
if let Err(err) = renderer.stop() {
|
||||||
|
warn!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
error = %err,
|
||||||
|
"Failed to stop renderer when timer expired"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the timer
|
||||||
|
renderer.cancel_sleep_timer();
|
||||||
|
|
||||||
|
// Emit TimerExpired event
|
||||||
|
event_bus_for_timer.broadcast(RendererEvent::TimerExpired {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from tick tracking
|
||||||
|
last_tick.remove(&renderer_id);
|
||||||
|
} else {
|
||||||
|
// Emit tick event every second
|
||||||
|
let should_emit_tick = last_tick
|
||||||
|
.get(&renderer_id)
|
||||||
|
.map(|&last| remaining_seconds != last)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_emit_tick {
|
||||||
|
event_bus_for_timer.broadcast(RendererEvent::TimerTick {
|
||||||
|
id: renderer_id.clone(),
|
||||||
|
remaining_seconds,
|
||||||
|
});
|
||||||
|
last_tick.insert(renderer_id, remaining_seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
registry,
|
registry,
|
||||||
// udn_cache,
|
// udn_cache,
|
||||||
@@ -554,7 +640,7 @@ impl ControlPoint {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Get queue length before clearing
|
// Get queue length before clearing
|
||||||
let removed = renderer.get_queue().upcoming_len().unwrap_or(0);
|
let removed = renderer.upcoming_len().unwrap_or(0);
|
||||||
|
|
||||||
renderer.clear_queue()?;
|
renderer.clear_queue()?;
|
||||||
|
|
||||||
@@ -611,11 +697,8 @@ impl ControlPoint {
|
|||||||
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let new_len = {
|
renderer.enqueue_items(items, mode)?;
|
||||||
let mut queue = renderer.get_queue_mut();
|
let new_len = renderer.upcoming_len()?;
|
||||||
queue.enqueue_items(items, mode)?;
|
|
||||||
queue.upcoming_len()?
|
|
||||||
};
|
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
@@ -703,7 +786,6 @@ impl ControlPoint {
|
|||||||
|
|
||||||
// Get queue from renderer (works for all backends)
|
// Get queue from renderer (works for all backends)
|
||||||
let queue_snapshot = renderer
|
let queue_snapshot = renderer
|
||||||
.get_queue()
|
|
||||||
.queue_snapshot()
|
.queue_snapshot()
|
||||||
.map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?;
|
.map_err(|e| anyhow!("Failed to get queue snapshot: {}", e))?;
|
||||||
|
|
||||||
@@ -869,7 +951,7 @@ impl ControlPoint {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Use generic queue access (works for all backends)
|
// Use generic queue access (works for all backends)
|
||||||
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
|
let Some((item, remaining)) = renderer.peek_current()? else {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"play_current_from_queue: queue is empty or no current item"
|
"play_current_from_queue: queue is empty or no current item"
|
||||||
@@ -889,9 +971,8 @@ impl ControlPoint {
|
|||||||
// when renderer sends Stopped event during SetAVTransportURI
|
// when renderer sends Stopped event during SetAVTransportURI
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
|
|
||||||
// Start playback
|
// Start playback using play_from_queue which preserves the queue
|
||||||
let didl_metadata = playback_item_to_didl(&item);
|
if let Err(err) = renderer.play_from_queue() {
|
||||||
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
|
|
||||||
error!(
|
error!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
@@ -922,61 +1003,50 @@ impl ControlPoint {
|
|||||||
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Dequeue next item
|
// Check if queue is empty before trying to play next
|
||||||
let Some((item, remaining_after)) = renderer.get_queue_mut().dequeue_next()? else {
|
if renderer.len()? == 0 {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"play_next_from_queue: queue is empty"
|
"play_next_from_queue: queue is empty"
|
||||||
);
|
);
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
|
|
||||||
debug!(
|
|
||||||
renderer = renderer_id.0.as_str(),
|
|
||||||
queue_after = remaining_after,
|
|
||||||
uri = item.uri.as_str(),
|
|
||||||
"Dequeued next playback item"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Temporarily disable auto-advance to prevent race condition
|
// Temporarily disable auto-advance to prevent race condition
|
||||||
// when renderer sends Stopped event during SetAVTransportURI
|
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
|
|
||||||
// Start playback
|
// Use the backend's play_next which handles queue advancement correctly for each backend type
|
||||||
let didl_metadata = playback_item_to_didl(&item);
|
if let Err(err) = renderer.play_next_from_queue() {
|
||||||
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
|
|
||||||
error!(
|
error!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to start playback for queued item"
|
"Failed to play next item from queue"
|
||||||
);
|
);
|
||||||
// Try to requeue the item
|
|
||||||
let _ = renderer
|
|
||||||
.get_queue_mut()
|
|
||||||
.enqueue_items(vec![item.clone()], EnqueueMode::InsertAfterCurrent);
|
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save metadata for current track
|
// Get current item metadata for tracking
|
||||||
let metadata = playback_item_track_metadata(&item);
|
if let Some((item, _)) = renderer.peek_current()? {
|
||||||
renderer.set_last_metadata(Some(metadata));
|
let metadata = playback_item_track_metadata(&item);
|
||||||
|
renderer.set_last_metadata(Some(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
queue_len = remaining_after,
|
"Playing next item from queue"
|
||||||
"Started playback from queue"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch next track if supported
|
// Prefetch next track if supported
|
||||||
self.prefetch_next_track(&renderer, renderer_id);
|
self.prefetch_next_track(&renderer, renderer_id);
|
||||||
|
|
||||||
// Emit QueueUpdated event
|
// Emit QueueUpdated event
|
||||||
|
let queue_length = renderer.len().unwrap_or(0);
|
||||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||||
id: renderer_id.clone(),
|
id: renderer_id.clone(),
|
||||||
queue_length: remaining_after,
|
queue_length,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -990,7 +1060,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the next item from the queue using peek_current
|
// Get the next item from the queue using peek_current
|
||||||
let Ok(Some((_, remaining))) = renderer.get_queue_mut().peek_current() else {
|
let Ok(Some((_, remaining))) = renderer.peek_current() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -998,7 +1068,7 @@ impl ControlPoint {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let queue_snapshot = match renderer.get_queue().queue_snapshot() {
|
let queue_snapshot = match renderer.queue_snapshot() {
|
||||||
Ok(snapshot) => snapshot,
|
Ok(snapshot) => snapshot,
|
||||||
Err(_) => return,
|
Err(_) => return,
|
||||||
};
|
};
|
||||||
@@ -1033,37 +1103,26 @@ impl ControlPoint {
|
|||||||
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Set queue index
|
// Check if index is valid
|
||||||
renderer.get_queue_mut().set_index(Some(index))?;
|
if index >= renderer.len()? {
|
||||||
|
|
||||||
let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else {
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
index, "play_queue_index: no item at index"
|
index, "play_queue_index: index out of bounds"
|
||||||
);
|
);
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
|
|
||||||
debug!(
|
|
||||||
renderer = renderer_id.0.as_str(),
|
|
||||||
index,
|
|
||||||
queue_len = remaining + 1,
|
|
||||||
uri = item.uri.as_str(),
|
|
||||||
"Playing item at index from queue"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Temporarily disable auto-advance to prevent race condition
|
// Temporarily disable auto-advance to prevent race condition
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
|
|
||||||
// Start playback
|
// Use the backend's play_from_index which handles everything correctly
|
||||||
let didl_metadata = playback_item_to_didl(&item);
|
if let Err(err) = renderer.play_from_index(index) {
|
||||||
if let Err(err) = renderer.play_uri(&item.uri, &didl_metadata) {
|
|
||||||
error!(
|
error!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
index,
|
index,
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to start playback for queued item at index"
|
"Failed to play from queue index"
|
||||||
);
|
);
|
||||||
renderer.set_playback_source(PlaybackSource::None);
|
renderer.set_playback_source(PlaybackSource::None);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
@@ -1071,14 +1130,14 @@ impl ControlPoint {
|
|||||||
|
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
index,
|
index, "Queue playback started at index"
|
||||||
uri = item.uri.as_str(),
|
|
||||||
"Queue playback started at index"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save metadata
|
// Get current item metadata for tracking
|
||||||
let metadata = playback_item_track_metadata(&item);
|
if let Some((item, _)) = renderer.peek_current()? {
|
||||||
renderer.set_last_metadata(Some(metadata));
|
let metadata = playback_item_track_metadata(&item);
|
||||||
|
renderer.set_last_metadata(Some(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1184,7 +1243,7 @@ impl ControlPoint {
|
|||||||
|
|
||||||
// Clear the local queue (detach binding + clear runtime queue structure)
|
// Clear the local queue (detach binding + clear runtime queue structure)
|
||||||
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
|
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
|
||||||
renderer.get_queue_mut().clear_queue()?;
|
renderer.clear_queue()?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
|
|||||||
@@ -434,6 +434,26 @@ pub enum RendererEvent {
|
|||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
binding: Option<PlaylistBinding>,
|
binding: Option<PlaylistBinding>,
|
||||||
},
|
},
|
||||||
|
TimerStarted {
|
||||||
|
id: DeviceId,
|
||||||
|
duration_seconds: u32,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
},
|
||||||
|
TimerUpdated {
|
||||||
|
id: DeviceId,
|
||||||
|
duration_seconds: u32,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
},
|
||||||
|
TimerTick {
|
||||||
|
id: DeviceId,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
},
|
||||||
|
TimerExpired {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
|
TimerCancelled {
|
||||||
|
id: DeviceId,
|
||||||
|
},
|
||||||
Online {
|
Online {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
info: DeviceBasicInfo,
|
info: DeviceBasicInfo,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -13,10 +14,13 @@ use crate::linkplay_client::extract_linkplay_host;
|
|||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||||
|
use crate::queue::MusicQueue;
|
||||||
|
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
|
||||||
/// Raw response from Arylic MCU+PINFGET command
|
/// Raw response from Arylic MCU+PINFGET command
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -40,6 +44,7 @@ pub struct ArylicTcpRenderer {
|
|||||||
host: String,
|
host: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArylicTcpRenderer {
|
impl ArylicTcpRenderer {
|
||||||
@@ -111,10 +116,13 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
host,
|
host,
|
||||||
port: ARYLIC_TCP_PORT,
|
port: ARYLIC_TCP_PORT,
|
||||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||||
|
queue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +217,135 @@ impl PlaybackPosition for ArylicTcpRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for ArylicTcpRenderer {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
&self.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for ArylicTcpRenderer {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
|
let current_index = match queue.current_index()? {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
if queue.len()? > 0 {
|
||||||
|
queue.set_index(Some(0))?;
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = queue
|
||||||
|
.get_item(current_index)?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||||
|
|
||||||
|
let uri = item.uri.clone();
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
self.play_uri(&uri, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.advance()? {
|
||||||
|
return Err(ControlPointError::QueueError("No next track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.rewind()? {
|
||||||
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(index))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for ArylicTcpRenderer {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().track_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().id_to_position(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().position_to_id(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().set_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().sync_queue(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().get_item(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().replace_item(index, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ArylicPlaybackInfo {
|
struct ArylicPlaybackInfo {
|
||||||
status_raw: String,
|
status_raw: String,
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
// pmocontrol/src/capabilities.rs
|
// pmocontrol/src/capabilities.rs
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::queue::MusicQueue;
|
||||||
use crate::{errors::ControlPointError, model::PlaybackState};
|
use crate::{errors::ControlPointError, model::PlaybackState};
|
||||||
|
|
||||||
|
/// Backend-specific operations for renderers.
|
||||||
|
///
|
||||||
|
/// This trait provides access to backend-specific resources like the queue.
|
||||||
|
pub trait RendererBackend {
|
||||||
|
/// Returns a reference to the queue associated with this backend.
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue-aware transport control operations.
|
||||||
|
///
|
||||||
|
/// These operations combine queue management with transport control,
|
||||||
|
/// allowing navigation (next/previous) and track selection from the queue.
|
||||||
|
pub trait QueueTransportControl {
|
||||||
|
/// Play the next track from the queue.
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
/// Play the previous track from the queue.
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
/// Play from the queue at the current index (or initialize to 0 if not set).
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError>;
|
||||||
|
|
||||||
|
/// Play from a specific index in the queue.
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError>;
|
||||||
|
}
|
||||||
|
|
||||||
/// Logical playback position across backends.
|
/// Logical playback position across backends.
|
||||||
///
|
///
|
||||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||||
|
|||||||
@@ -17,16 +17,19 @@ use std::thread::JoinHandle;
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
use crate::DeviceIdentity;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::discovery::chromecast_discovery::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
extract_host_from_location, extract_port_from_location,
|
||||||
VolumeControl,
|
|
||||||
};
|
};
|
||||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
|
||||||
use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
|
TransportControl, VolumeControl,
|
||||||
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||||
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
|
||||||
use rust_cast::{
|
use rust_cast::{
|
||||||
CastDevice, ChannelMessage,
|
CastDevice, ChannelMessage,
|
||||||
@@ -55,6 +58,7 @@ pub struct ChromecastRenderer {
|
|||||||
/// Handle to the active heartbeat thread, if any.
|
/// Handle to the active heartbeat thread, if any.
|
||||||
/// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management.
|
/// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management.
|
||||||
thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ChromecastRenderer {
|
impl std::fmt::Debug for ChromecastRenderer {
|
||||||
@@ -88,20 +92,22 @@ fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>, Con
|
|||||||
// Ensure rustls crypto provider is initialized before any TLS connection
|
// Ensure rustls crypto provider is initialized before any TLS connection
|
||||||
ensure_crypto_provider_initialized();
|
ensure_crypto_provider_initialized();
|
||||||
|
|
||||||
let device = CastDevice::connect_without_host_verification(host, port)
|
let device = CastDevice::connect_without_host_verification(host, port).map_err(|e| {
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?;
|
ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(DEFAULT_DESTINATION_ID.to_string())
|
.connect(DEFAULT_DESTINATION_ID.to_string())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Send initial gre to establish heartbeat communication
|
// Send initial gre to establish heartbeat communication
|
||||||
// This is critical per rust_caster.rs example
|
// This is critical per rust_caster.rs example
|
||||||
device
|
device.heartbeat.ping().map_err(|e| {
|
||||||
.heartbeat
|
ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e))
|
||||||
.ping()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(device)
|
Ok(device)
|
||||||
}
|
}
|
||||||
@@ -116,7 +122,6 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -125,13 +130,18 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
|||||||
info.friendly_name()
|
info.friendly_name()
|
||||||
);
|
);
|
||||||
|
|
||||||
let host = extract_host_from_location(info.location())
|
let host = extract_host_from_location(info.location()).ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("Invalid Chromecast location: {}", info.location())))?;
|
ControlPointError::ChromecastError(format!(
|
||||||
|
"Invalid Chromecast location: {}",
|
||||||
|
info.location()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT);
|
let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT);
|
||||||
|
|
||||||
let stop_signal = Arc::new(Mutex::new(false));
|
let stop_signal = Arc::new(Mutex::new(false));
|
||||||
let thread_handle = Arc::new(Mutex::new(None));
|
let thread_handle = Arc::new(Mutex::new(None));
|
||||||
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"ChromecastRenderer created for {} with host={} port={}",
|
"ChromecastRenderer created for {} with host={} port={}",
|
||||||
@@ -145,13 +155,13 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
|||||||
port,
|
port,
|
||||||
stop_signal,
|
stop_signal,
|
||||||
thread_handle,
|
thread_handle,
|
||||||
|
queue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_backend(self) -> MusicRendererBackend {
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
MusicRendererBackend::Chromecast(self)
|
MusicRendererBackend::Chromecast(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for ChromecastRenderer {
|
impl TransportControl for ChromecastRenderer {
|
||||||
@@ -308,10 +318,9 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
let app = status
|
let app = status
|
||||||
.applications
|
.applications
|
||||||
@@ -322,13 +331,17 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_entry = media_status
|
let media_entry = media_status
|
||||||
.entries
|
.entries
|
||||||
@@ -349,10 +362,9 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
let app = status
|
let app = status
|
||||||
.applications
|
.applications
|
||||||
@@ -362,12 +374,16 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_entry = media_status
|
let media_entry = media_status
|
||||||
.entries
|
.entries
|
||||||
@@ -397,10 +413,9 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
// Also send stop command to the device
|
// Also send stop command to the device
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
let app = status
|
let app = status
|
||||||
.applications
|
.applications
|
||||||
@@ -410,12 +425,16 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_entry = media_status
|
let media_entry = media_status
|
||||||
.entries
|
.entries
|
||||||
@@ -437,10 +456,9 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
let app = status
|
let app = status
|
||||||
.applications
|
.applications
|
||||||
@@ -450,12 +468,16 @@ impl TransportControl for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_entry = media_status
|
let media_entry = media_status
|
||||||
.entries
|
.entries
|
||||||
@@ -481,10 +503,9 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
// If no app is running, return NoMedia
|
// If no app is running, return NoMedia
|
||||||
let app = match status.applications.first() {
|
let app = match status.applications.first() {
|
||||||
@@ -496,13 +517,17 @@ impl PlaybackStatus for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// If no media entry, return NoMedia
|
// If no media entry, return NoMedia
|
||||||
let media_entry = match media_status.entries.first() {
|
let media_entry = match media_status.entries.first() {
|
||||||
@@ -519,10 +544,9 @@ impl PlaybackPosition for ChromecastRenderer {
|
|||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
// Get receiver status to find the active app
|
// Get receiver status to find the active app
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
let app = status
|
let app = status
|
||||||
.applications
|
.applications
|
||||||
@@ -533,13 +557,17 @@ impl PlaybackPosition for ChromecastRenderer {
|
|||||||
device
|
device
|
||||||
.connection
|
.connection
|
||||||
.connect(app.transport_id.as_str())
|
.connect(app.transport_id.as_str())
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
// Get media status
|
// Get media status
|
||||||
let media_status = device
|
let media_status = device
|
||||||
.media
|
.media
|
||||||
.get_status(app.transport_id.as_str(), None)
|
.get_status(app.transport_id.as_str(), None)
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
.map_err(|e| {
|
||||||
|
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let media_entry = media_status
|
let media_entry = media_status
|
||||||
.entries
|
.entries
|
||||||
@@ -635,10 +663,9 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
if let Some(level) = status.volume.level {
|
if let Some(level) = status.volume.level {
|
||||||
Ok((level * 100.0) as u16)
|
Ok((level * 100.0) as u16)
|
||||||
@@ -653,10 +680,9 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let level = (volume as f32) / 100.0;
|
let level = (volume as f32) / 100.0;
|
||||||
device
|
device.receiver.set_volume(level).map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to set volume: {}", e))
|
||||||
.set_volume(level)
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -664,10 +690,9 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
let status = device
|
let status = device.receiver.get_status().map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
|
||||||
.get_status()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(status.volume.muted.unwrap_or(false))
|
Ok(status.volume.muted.unwrap_or(false))
|
||||||
}
|
}
|
||||||
@@ -677,11 +702,139 @@ impl VolumeControl for ChromecastRenderer {
|
|||||||
|
|
||||||
let device = connect_to_device(&self.host, self.port)?;
|
let device = connect_to_device(&self.host, self.port)?;
|
||||||
|
|
||||||
device
|
device.receiver.set_volume(mute).map_err(|e| {
|
||||||
.receiver
|
ControlPointError::ChromecastError(format!("Failed to set mute: {}", e))
|
||||||
.set_volume(mute)
|
})?;
|
||||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for ChromecastRenderer {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
&self.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for ChromecastRenderer {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
|
let current_index = match queue.current_index()? {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
if queue.len()? > 0 {
|
||||||
|
queue.set_index(Some(0))?;
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = queue
|
||||||
|
.get_item(current_index)?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||||
|
|
||||||
|
let uri = item.uri.clone();
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
self.play_uri(&uri, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.advance()? {
|
||||||
|
return Err(ControlPointError::QueueError("No next track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.rewind()? {
|
||||||
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(index))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for ChromecastRenderer {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().track_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().id_to_position(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().position_to_id(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().set_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().sync_queue(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().get_item(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().replace_item(index, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use ureq::Agent;
|
use ureq::Agent;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
use crate::DeviceIdentity;
|
||||||
use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode};
|
|
||||||
use crate::music_renderer::capabilities::{
|
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
|
||||||
VolumeControl,
|
|
||||||
};
|
|
||||||
use crate::music_renderer::time_utils::{parse_hhmmss_strict};
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::model::{RendererInfo, PlaybackState};
|
use crate::linkplay_client::{
|
||||||
|
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
|
||||||
|
};
|
||||||
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
|
use crate::music_renderer::capabilities::{
|
||||||
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
|
TransportControl, VolumeControl,
|
||||||
|
};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
|
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
||||||
|
use crate::queue::MusicQueue;
|
||||||
|
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
|
|
||||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
|
|
||||||
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LinkPlayRenderer {
|
pub struct LinkPlayRenderer {
|
||||||
host: String,
|
host: String,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for LinkPlayRenderer {
|
impl fmt::Debug for LinkPlayRenderer {
|
||||||
@@ -34,7 +39,6 @@ impl fmt::Debug for LinkPlayRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LinkPlayRenderer {
|
impl LinkPlayRenderer {
|
||||||
|
|
||||||
fn agent(&self) -> Agent {
|
fn agent(&self) -> Agent {
|
||||||
build_agent(self.timeout)
|
build_agent(self.timeout)
|
||||||
}
|
}
|
||||||
@@ -44,10 +48,12 @@ impl LinkPlayRenderer {
|
|||||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||||
self.host, command
|
self.host, command
|
||||||
);
|
);
|
||||||
self.agent()
|
self.agent().get(&url).call().map_err(|_| {
|
||||||
.get(&url)
|
ControlPointError::ArilycTcpError(format!(
|
||||||
.call()
|
"LinkPlay command {} failed for {}",
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?;
|
command, self.host
|
||||||
|
))
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,20 +64,25 @@ impl LinkPlayRenderer {
|
|||||||
|
|
||||||
impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
let host = extract_linkplay_host(&info.location())
|
let host = extract_linkplay_host(&info.location()).ok_or_else(|| {
|
||||||
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
ControlPointError::LinkPlayError(format!(
|
||||||
|
"Renderer {} has no valid LOCATION host",
|
||||||
|
info.udn()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
host,
|
host,
|
||||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||||
|
queue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn to_backend(self) -> MusicRendererBackend {
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
MusicRendererBackend::LinkPlay(self)
|
MusicRendererBackend::LinkPlay(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for LinkPlayRenderer {
|
impl TransportControl for LinkPlayRenderer {
|
||||||
@@ -129,6 +140,131 @@ impl PlaybackPosition for LinkPlayRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for LinkPlayRenderer {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
&self.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for LinkPlayRenderer {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
|
let current_index = match queue.current_index()? {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
if queue.len()? > 0 {
|
||||||
|
queue.set_index(Some(0))?;
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = queue
|
||||||
|
.get_item(current_index)?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||||
|
|
||||||
|
let uri = item.uri.clone();
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
self.play_uri(&uri, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.advance()? {
|
||||||
|
return Err(ControlPointError::QueueError("No next track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.rewind()? {
|
||||||
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(index))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for LinkPlayRenderer {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().track_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().id_to_position(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().position_to_id(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().set_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().sync_queue(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().get_item(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().replace_item(index, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod capabilities;
|
|||||||
mod chromecast_renderer;
|
mod chromecast_renderer;
|
||||||
|
|
||||||
mod musicrenderer;
|
mod musicrenderer;
|
||||||
|
mod sleep_timer;
|
||||||
pub mod time_utils;
|
pub mod time_utils;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -18,6 +19,7 @@ pub use crate::music_renderer::capabilities::{
|
|||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||||
};
|
};
|
||||||
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||||
|
pub use crate::music_renderer::sleep_timer::SleepTimer;
|
||||||
use crate::{
|
use crate::{
|
||||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol
|
|||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
||||||
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||||
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||||
|
use crate::music_renderer::sleep_timer::SleepTimer;
|
||||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||||
use crate::online::DeviceConnectionState;
|
use crate::online::DeviceConnectionState;
|
||||||
use crate::queue::{
|
use crate::queue::{
|
||||||
@@ -77,6 +79,8 @@ struct MusicRendererState {
|
|||||||
playback_source: PlaybackSource,
|
playback_source: PlaybackSource,
|
||||||
/// Flag to distinguish user-requested stop from automatic events.
|
/// Flag to distinguish user-requested stop from automatic events.
|
||||||
user_stop_requested: bool,
|
user_stop_requested: bool,
|
||||||
|
/// Sleep timer for auto-stop functionality.
|
||||||
|
sleep_timer: SleepTimer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -84,7 +88,6 @@ pub struct MusicRenderer {
|
|||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
connection: Arc<Mutex<DeviceConnectionState>>,
|
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
|
||||||
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
||||||
state: Arc<Mutex<MusicRendererState>>,
|
state: Arc<Mutex<MusicRendererState>>,
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,6 @@ impl MusicRenderer {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
|
||||||
) -> Arc<MusicRenderer> {
|
) -> Arc<MusicRenderer> {
|
||||||
let connection = DeviceConnectionState::new();
|
let connection = DeviceConnectionState::new();
|
||||||
|
|
||||||
@@ -101,7 +103,6 @@ impl MusicRenderer {
|
|||||||
info,
|
info,
|
||||||
connection: Arc::new(Mutex::new(connection)),
|
connection: Arc::new(Mutex::new(connection)),
|
||||||
backend,
|
backend,
|
||||||
queue,
|
|
||||||
playlist_binding: Arc::new(Mutex::new(None)),
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||||
};
|
};
|
||||||
@@ -112,13 +113,11 @@ impl MusicRenderer {
|
|||||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
||||||
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
||||||
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
||||||
let queue = MusicQueue::make_from_renderer_info(info)?;
|
|
||||||
|
|
||||||
let renderer = MusicRenderer {
|
let renderer = MusicRenderer {
|
||||||
info: info.clone(),
|
info: info.clone(),
|
||||||
connection,
|
connection,
|
||||||
backend,
|
backend,
|
||||||
queue,
|
|
||||||
playlist_binding: Arc::new(Mutex::new(None)),
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||||
};
|
};
|
||||||
@@ -184,9 +183,9 @@ impl MusicRenderer {
|
|||||||
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
||||||
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
||||||
// Clear the queue first
|
// Clear the queue first
|
||||||
self.queue
|
self.backend
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Queue mutex poisoned")
|
.expect("Backend mutex poisoned")
|
||||||
.clear_queue()?;
|
.clear_queue()?;
|
||||||
|
|
||||||
// Then stop playback (ignore errors if already stopped)
|
// Then stop playback (ignore errors if already stopped)
|
||||||
@@ -207,9 +206,9 @@ impl MusicRenderer {
|
|||||||
/// Get the current queue snapshot.
|
/// Get the current queue snapshot.
|
||||||
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
let mut snapshot = self
|
let mut snapshot = self
|
||||||
.queue
|
.backend
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Queue mutex poisoned")
|
.expect("Backend mutex poisoned")
|
||||||
.queue_snapshot()?;
|
.queue_snapshot()?;
|
||||||
|
|
||||||
// Enrich snapshot with playlist_id from binding if available
|
// Enrich snapshot with playlist_id from binding if available
|
||||||
@@ -229,72 +228,42 @@ impl MusicRenderer {
|
|||||||
/// Get the current queue item without advancing.
|
/// Get the current queue item without advancing.
|
||||||
/// Returns the item and count of remaining items after current.
|
/// Returns the item and count of remaining items after current.
|
||||||
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
self.queue
|
self.backend
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Queue mutex poisoned")
|
.expect("Backend mutex poisoned")
|
||||||
.peek_current()
|
.peek_current()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the count of items remaining after the current index.
|
/// Get the count of items remaining after the current index.
|
||||||
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||||
self.queue
|
self.backend
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Queue mutex poisoned")
|
.expect("Backend mutex poisoned")
|
||||||
.upcoming_len()
|
.upcoming_len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play the current item from the queue.
|
/// Play the current item from the queue.
|
||||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
self.backend
|
||||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
// Get the current item from the queue
|
.play_from_queue()
|
||||||
let (item, _remaining) = queue.peek_current()?.ok_or_else(|| {
|
|
||||||
ControlPointError::QueueError("Queue is empty, cannot play current".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Build DIDL metadata if available
|
|
||||||
let metadata_xml = item
|
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Play the URI using the backend
|
|
||||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Advance to and play the next item from the queue.
|
/// Advance to and play the next item from the queue.
|
||||||
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
||||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
|
||||||
|
|
||||||
// Dequeue the next item
|
|
||||||
let (item, _remaining) = queue
|
|
||||||
.dequeue_next()?
|
|
||||||
.ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?;
|
|
||||||
|
|
||||||
// Build DIDL metadata if available
|
|
||||||
let metadata_xml = item
|
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Play the URI using the backend
|
|
||||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transport control: play URI with metadata
|
|
||||||
pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
|
||||||
self.backend
|
self.backend
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Backend mutex poisoned")
|
.expect("Backend mutex poisoned")
|
||||||
.play_uri(uri, metadata)
|
.play_next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play from a specific index in the queue.
|
||||||
|
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
self.backend
|
||||||
|
.lock()
|
||||||
|
.expect("Backend mutex poisoned")
|
||||||
|
.play_from_index(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport control: play
|
/// Transport control: play
|
||||||
@@ -303,18 +272,16 @@ impl MusicRenderer {
|
|||||||
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
||||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||||
// Vérifier si on a une queue non vide
|
// Vérifier si on a une queue non vide
|
||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
let queue_not_empty = queue.len().unwrap_or(0) > 0;
|
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
||||||
drop(queue); // Libérer le lock avant l'appel au backend
|
|
||||||
|
|
||||||
if queue_not_empty {
|
if queue_not_empty {
|
||||||
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
|
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
|
||||||
// peek_current() initialise automatiquement l'index à 0 si nécessaire
|
|
||||||
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
||||||
self.play_current_from_queue()
|
backend.play_from_queue()
|
||||||
} else {
|
} else {
|
||||||
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
|
// Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
|
||||||
self.backend.lock().expect("Backend mutex poisoned").play()
|
backend.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,14 +470,20 @@ impl MusicRenderer {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the number of items in the queue.
|
||||||
|
pub fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.len()
|
||||||
|
}
|
||||||
|
|
||||||
/// Add items to the queue using the specified enqueue mode.
|
/// Add items to the queue using the specified enqueue mode.
|
||||||
pub fn enqueue_items(
|
pub fn enqueue_items(
|
||||||
&self,
|
&self,
|
||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
mode: EnqueueMode,
|
mode: EnqueueMode,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
queue.enqueue_items(items, mode)
|
backend.enqueue_items(items, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronize the queue with new items while preserving the current track.
|
/// Synchronize the queue with new items while preserving the current track.
|
||||||
@@ -520,21 +493,33 @@ impl MusicRenderer {
|
|||||||
/// - If the current track is NOT in the new items, it's preserved as the first item
|
/// - If the current track is NOT in the new items, it's preserved as the first item
|
||||||
/// - If there's no current track, the queue is simply replaced
|
/// - If there's no current track, the queue is simply replaced
|
||||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
queue.sync_queue(items)
|
backend.sync_queue(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the current queue index (for advanced use).
|
/// Set the current queue index (for advanced use).
|
||||||
/// Note: This does NOT start playback. Use select_queue_track() to play.
|
/// Note: This does NOT start playback. Use select_queue_track() to play.
|
||||||
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
queue.set_index(index)
|
backend.set_index(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears the renderer's queue using the generic QueueBackend trait.
|
/// Clears the renderer's queue using the generic QueueBackend trait.
|
||||||
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
queue.clear_queue()
|
backend.clear_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dequeues and returns the next item from the queue.
|
||||||
|
pub fn dequeue_next(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||||
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.dequeue_next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the current index in the queue.
|
||||||
|
pub fn set_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.set_index(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the entire queue with new items and sets the current index.
|
/// Replaces the entire queue with new items and sets the current index.
|
||||||
@@ -544,8 +529,8 @@ impl MusicRenderer {
|
|||||||
items: Vec<PlaybackItem>,
|
items: Vec<PlaybackItem>,
|
||||||
current_index: Option<usize>,
|
current_index: Option<usize>,
|
||||||
) -> Result<(), ControlPointError> {
|
) -> Result<(), ControlPointError> {
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
queue.replace_queue(items, current_index)
|
backend.replace_queue(items, current_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a track to the queue.
|
/// Adds a track to the queue.
|
||||||
@@ -575,34 +560,14 @@ impl MusicRenderer {
|
|||||||
/// Converts the track ID to a position using the generic QueueBackend trait,
|
/// Converts the track ID to a position using the generic QueueBackend trait,
|
||||||
/// then plays the track.
|
/// then plays the track.
|
||||||
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
// Convert track_id to index
|
||||||
|
let index = {
|
||||||
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.id_to_position(track_id)?
|
||||||
|
};
|
||||||
|
|
||||||
// Convert track ID to position using generic QueueBackend trait
|
// Play from that index
|
||||||
let position = queue.id_to_position(track_id)?;
|
self.play_from_index(index)
|
||||||
drop(queue);
|
|
||||||
|
|
||||||
// Set the index
|
|
||||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
|
||||||
queue.set_index(Some(position))?;
|
|
||||||
|
|
||||||
// Get the item to play
|
|
||||||
let item = queue
|
|
||||||
.get_item(position)?
|
|
||||||
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
|
|
||||||
drop(queue);
|
|
||||||
|
|
||||||
// Build metadata XML
|
|
||||||
let metadata_xml = item
|
|
||||||
.metadata
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Play the item using TransportControl
|
|
||||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
|
||||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronizes the queue state with the backend.
|
/// Synchronizes the queue state with the backend.
|
||||||
@@ -610,9 +575,9 @@ impl MusicRenderer {
|
|||||||
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
||||||
/// For others, this is essentially a no-op (just reads the current state).
|
/// For others, this is essentially a no-op (just reads the current state).
|
||||||
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
// Calling queue_snapshot() triggers a refresh for backends that need it
|
// Calling queue_snapshot() triggers a refresh for backends that need it
|
||||||
let _ = queue.queue_snapshot()?;
|
let _ = backend.queue_snapshot()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,29 +585,25 @@ impl MusicRenderer {
|
|||||||
///
|
///
|
||||||
/// This is primarily for backends with persistent queues (OpenHome).
|
/// This is primarily for backends with persistent queues (OpenHome).
|
||||||
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
|
||||||
// Get current track ID using generic QueueBackend trait
|
// Get current track ID using generic QueueBackend trait
|
||||||
let track_id = queue
|
let track_id = backend
|
||||||
.current_track()?
|
.current_track()?
|
||||||
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
||||||
|
|
||||||
drop(queue);
|
drop(backend);
|
||||||
|
|
||||||
// Play it using select_queue_track
|
// Play it using select_queue_track
|
||||||
self.select_queue_track(track_id)
|
self.select_queue_track(track_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a reference to the queue (read-only access via lock).
|
/// Plays from the queue at the current position.
|
||||||
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
///
|
||||||
pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
/// Uses the backend's play_from_queue which preserves the queue for all backends.
|
||||||
self.queue.lock().unwrap()
|
pub fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
}
|
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||||
|
backend.play_from_queue()
|
||||||
/// Returns a mutable reference to the queue (write access via lock).
|
|
||||||
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
|
||||||
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
|
||||||
self.queue.lock().unwrap()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Playback State Management ---
|
// --- Playback State Management ---
|
||||||
@@ -695,10 +656,84 @@ impl MusicRenderer {
|
|||||||
state.user_stop_requested = false;
|
state.user_stop_requested = false;
|
||||||
was_requested
|
was_requested
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Sleep Timer Management ---
|
||||||
|
|
||||||
|
/// Starts the sleep timer with the given duration in seconds.
|
||||||
|
/// Maximum duration is 2 hours (7200 seconds).
|
||||||
|
///
|
||||||
|
/// Returns the remaining seconds after starting.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
|
||||||
|
pub fn start_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.sleep_timer
|
||||||
|
.start(duration_seconds)
|
||||||
|
.map_err(|e| ControlPointError::ControlPoint(e))?;
|
||||||
|
|
||||||
|
Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the sleep timer duration. Resets the timer to the new duration from now.
|
||||||
|
///
|
||||||
|
/// Returns the new remaining seconds.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
|
||||||
|
pub fn update_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.sleep_timer
|
||||||
|
.update(duration_seconds)
|
||||||
|
.map_err(|e| ControlPointError::ControlPoint(e))?;
|
||||||
|
|
||||||
|
Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the sleep timer.
|
||||||
|
pub fn cancel_sleep_timer(&self) {
|
||||||
|
self.state.lock().unwrap().sleep_timer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining seconds of the sleep timer, or None if no timer is active.
|
||||||
|
pub fn sleep_timer_remaining(&self) -> Option<u32> {
|
||||||
|
self.state.lock().unwrap().sleep_timer.remaining_seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured duration of the sleep timer in seconds.
|
||||||
|
pub fn sleep_timer_duration(&self) -> u32 {
|
||||||
|
self.state.lock().unwrap().sleep_timer.duration_seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the sleep timer is active.
|
||||||
|
pub fn is_sleep_timer_active(&self) -> bool {
|
||||||
|
self.state.lock().unwrap().sleep_timer.is_active()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the sleep timer has expired.
|
||||||
|
pub fn is_sleep_timer_expired(&self) -> bool {
|
||||||
|
self.state.lock().unwrap().sleep_timer.is_expired()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the sleep timer state as a tuple (is_active, duration_seconds, remaining_seconds).
|
||||||
|
pub fn sleep_timer_state(&self) -> (bool, u32, Option<u32>) {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
(
|
||||||
|
state.sleep_timer.is_active(),
|
||||||
|
state.sleep_timer.duration_seconds(),
|
||||||
|
state.sleep_timer.remaining_seconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
||||||
fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String {
|
pub(crate) fn build_didl_lite_metadata(
|
||||||
|
metadata: &TrackMetadata,
|
||||||
|
uri: &str,
|
||||||
|
protocol_info: &str,
|
||||||
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||||
<item id="0" parentID="-1" restricted="1">
|
<item id="0" parentID="-1" restricted="1">
|
||||||
@@ -1014,3 +1049,217 @@ impl PlaybackPosition for MusicRendererBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for MusicRendererBackend {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.queue(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.queue(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.queue(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.queue(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.queue(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for MusicRendererBackend {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.play_from_queue(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.play_from_queue(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.play_from_queue(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.play_from_queue(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play_from_queue(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_queue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.play_next(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.play_next(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.play_next(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.play_next(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play_next(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.play_previous(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.play_previous(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.play_previous(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.play_previous(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play_previous(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_previous(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.play_from_index(index),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.play_from_index(index),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.play_from_index(index),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.play_from_index(index),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.play_from_index(index),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_index(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for MusicRendererBackend {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.len(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.len(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.len(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.len(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.len(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.track_ids(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.track_ids(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.track_ids(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.track_ids(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.track_ids(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.track_ids(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.id_to_position(id),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.id_to_position(id),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.id_to_position(id),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.id_to_position(id),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.id_to_position(id),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.id_to_position(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.position_to_id(id),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.position_to_id(id),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.position_to_id(id),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.position_to_id(id),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.position_to_id(id),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.position_to_id(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.current_track(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.current_track(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.current_track(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.current_track(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.current_track(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_track(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.current_index(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.current_index(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.current_index(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.current_index(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.current_index(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_index(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.queue_snapshot(),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.queue_snapshot(),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.queue_snapshot(),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.queue_snapshot(),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.queue_snapshot(),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue_snapshot(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.set_index(index),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.set_index(index),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.set_index(index),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.set_index(index),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.set_index(index),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_index(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.replace_queue(items, current_index),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.replace_queue(items, current_index),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.replace_queue(items, current_index),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.replace_queue(items, current_index),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.replace_queue(items, current_index),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
||||||
|
upnp.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.sync_queue(items),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.sync_queue(items),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.sync_queue(items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.get_item(index),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.get_item(index),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.get_item(index),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.get_item(index),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.get_item(index),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.get_item(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.replace_item(index, item),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.replace_item(index, item),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.replace_item(index, item),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.replace_item(index, item),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.replace_item(index, item),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.replace_item(index, item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
match self {
|
||||||
|
MusicRendererBackend::Upnp(r) => r.enqueue_items(items, mode),
|
||||||
|
MusicRendererBackend::OpenHome(r) => r.enqueue_items(items, mode),
|
||||||
|
MusicRendererBackend::LinkPlay(r) => r.enqueue_items(items, mode),
|
||||||
|
MusicRendererBackend::ArylicTcp(r) => r.enqueue_items(items, mode),
|
||||||
|
MusicRendererBackend::Chromecast(cc) => cc.enqueue_items(items, mode),
|
||||||
|
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.enqueue_items(items, mode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
use crate::DeviceIdentity;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
||||||
|
|
||||||
@@ -12,6 +15,7 @@ use crate::music_renderer::openhome::{
|
|||||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||||
build_time_client, build_volume_client,
|
build_time_client, build_volume_client,
|
||||||
};
|
};
|
||||||
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
use crate::upnp_clients::{
|
use crate::upnp_clients::{
|
||||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||||
OhTimeClient, OhVolumeClient,
|
OhTimeClient, OhVolumeClient,
|
||||||
@@ -27,6 +31,7 @@ pub struct OpenHomeRenderer {
|
|||||||
product_client: Option<OhProductClient>,
|
product_client: Option<OhProductClient>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
radio_client: Option<OhRadioClient>,
|
radio_client: Option<OhRadioClient>,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeRenderer {
|
impl OpenHomeRenderer {
|
||||||
@@ -37,6 +42,7 @@ impl OpenHomeRenderer {
|
|||||||
volume_client: Option<OhVolumeClient>,
|
volume_client: Option<OhVolumeClient>,
|
||||||
product_client: Option<OhProductClient>,
|
product_client: Option<OhProductClient>,
|
||||||
radio_client: Option<OhRadioClient>,
|
radio_client: Option<OhRadioClient>,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
playlist,
|
playlist,
|
||||||
@@ -45,6 +51,7 @@ impl OpenHomeRenderer {
|
|||||||
volume_client,
|
volume_client,
|
||||||
product_client,
|
product_client,
|
||||||
radio_client,
|
radio_client,
|
||||||
|
queue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +187,9 @@ impl OpenHomeRenderer {
|
|||||||
|
|
||||||
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
|
// Create the OpenHome queue
|
||||||
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||||
|
|
||||||
let renderer = OpenHomeRenderer::new(
|
let renderer = OpenHomeRenderer::new(
|
||||||
build_playlist_client(&info),
|
build_playlist_client(&info),
|
||||||
build_info_client(&info),
|
build_info_client(&info),
|
||||||
@@ -187,6 +197,7 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
|||||||
build_volume_client(&info),
|
build_volume_client(&info),
|
||||||
build_product_client(&info),
|
build_product_client(&info),
|
||||||
build_radio_client(&info),
|
build_radio_client(&info),
|
||||||
|
queue,
|
||||||
);
|
);
|
||||||
|
|
||||||
if renderer.has_any_openhome_service() {
|
if renderer.has_any_openhome_service() {
|
||||||
@@ -204,6 +215,12 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for OpenHomeRenderer {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
&self.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TransportControl for OpenHomeRenderer {
|
impl TransportControl for OpenHomeRenderer {
|
||||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||||
let playlist = self.playlist_client_for("play_uri")?;
|
let playlist = self.playlist_client_for("play_uri")?;
|
||||||
@@ -330,3 +347,133 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
|||||||
other => PlaybackState::Unknown(other.to_string()),
|
other => PlaybackState::Unknown(other.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for OpenHomeRenderer {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
|
if queue.current_index()?.is_none() {
|
||||||
|
if queue.len()? > 0 {
|
||||||
|
drop(queue);
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(0))?;
|
||||||
|
} else {
|
||||||
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let playlist = self.playlist_client_for("play_from_queue")?;
|
||||||
|
playlist.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.advance()? {
|
||||||
|
return Err(ControlPointError::QueueError("No next track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.rewind()? {
|
||||||
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
// For OpenHome, we need to convert index to track_id
|
||||||
|
let track_id = {
|
||||||
|
let queue = self.queue.lock().unwrap();
|
||||||
|
queue.position_to_id(index)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seek to the track by ID
|
||||||
|
let playlist = self.playlist_client_for("play_from_index")?;
|
||||||
|
playlist.seek_id(track_id)?;
|
||||||
|
|
||||||
|
// Update local queue index
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(index))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start playback
|
||||||
|
playlist.play()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for OpenHomeRenderer {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().track_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().id_to_position(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().position_to_id(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().set_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().sync_queue(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().get_item(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().replace_item(index, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
199
pmocontrol/src/music_renderer/sleep_timer.rs
Normal file
199
pmocontrol/src/music_renderer/sleep_timer.rs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
//! Sleep timer functionality for auto-stop feature.
|
||||||
|
//!
|
||||||
|
//! Provides a sleep timer that can automatically stop playback after a configured duration.
|
||||||
|
//! Maximum duration is 2 hours (7200 seconds).
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Sleep timer state for auto-stop functionality.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SleepTimer {
|
||||||
|
/// When the timer expires (None if no timer active).
|
||||||
|
end_time: Option<Instant>,
|
||||||
|
/// Total duration in seconds configured for the timer.
|
||||||
|
duration_seconds: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SleepTimer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
end_time: None,
|
||||||
|
duration_seconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SleepTimer {
|
||||||
|
/// Maximum timer duration in seconds (2 hours).
|
||||||
|
pub const MAX_DURATION: u32 = 7200;
|
||||||
|
|
||||||
|
/// Creates a new inactive timer.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining seconds, or None if no timer is active.
|
||||||
|
pub fn remaining_seconds(&self) -> Option<u32> {
|
||||||
|
self.end_time.map(|end| {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= end {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
end.duration_since(now).as_secs() as u32
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured duration in seconds.
|
||||||
|
pub fn duration_seconds(&self) -> u32 {
|
||||||
|
self.duration_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the timer is active.
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.end_time.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the timer has expired.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
self.end_time
|
||||||
|
.map(|end| Instant::now() >= end)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts or restarts the timer with the given duration in seconds.
|
||||||
|
/// Maximum duration is 2 hours (7200 seconds).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - duration is 0
|
||||||
|
/// - duration exceeds MAX_DURATION (7200 seconds)
|
||||||
|
pub fn start(&mut self, duration_seconds: u32) -> Result<(), String> {
|
||||||
|
if duration_seconds == 0 {
|
||||||
|
return Err("Duration must be greater than 0".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration_seconds > Self::MAX_DURATION {
|
||||||
|
return Err(format!(
|
||||||
|
"Duration cannot exceed {} seconds (2 hours)",
|
||||||
|
Self::MAX_DURATION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.duration_seconds = duration_seconds;
|
||||||
|
self.end_time =
|
||||||
|
Some(Instant::now() + std::time::Duration::from_secs(duration_seconds as u64));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the timer duration. Resets the timer to the new duration from now.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if:
|
||||||
|
/// - duration is 0
|
||||||
|
/// - duration exceeds MAX_DURATION (7200 seconds)
|
||||||
|
pub fn update(&mut self, duration_seconds: u32) -> Result<(), String> {
|
||||||
|
if duration_seconds == 0 {
|
||||||
|
return Err("Duration must be greater than 0".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if duration_seconds > Self::MAX_DURATION {
|
||||||
|
return Err(format!(
|
||||||
|
"Duration cannot exceed {} seconds (2 hours)",
|
||||||
|
Self::MAX_DURATION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.duration_seconds = duration_seconds;
|
||||||
|
self.end_time =
|
||||||
|
Some(Instant::now() + std::time::Duration::from_secs(duration_seconds as u64));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the timer.
|
||||||
|
pub fn cancel(&mut self) {
|
||||||
|
self.end_time = None;
|
||||||
|
self.duration_seconds = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_creation() {
|
||||||
|
let timer = SleepTimer::new();
|
||||||
|
assert!(!timer.is_active());
|
||||||
|
assert_eq!(timer.remaining_seconds(), None);
|
||||||
|
assert_eq!(timer.duration_seconds(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_start() {
|
||||||
|
let mut timer = SleepTimer::new();
|
||||||
|
assert!(timer.start(60).is_ok());
|
||||||
|
assert!(timer.is_active());
|
||||||
|
assert_eq!(timer.duration_seconds(), 60);
|
||||||
|
|
||||||
|
let remaining = timer.remaining_seconds().unwrap();
|
||||||
|
assert!(remaining <= 60 && remaining > 58); // Account for execution time
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_validation() {
|
||||||
|
let mut timer = SleepTimer::new();
|
||||||
|
|
||||||
|
// Zero duration should fail
|
||||||
|
assert!(timer.start(0).is_err());
|
||||||
|
|
||||||
|
// Exceeding max duration should fail
|
||||||
|
assert!(timer.start(SleepTimer::MAX_DURATION + 1).is_err());
|
||||||
|
|
||||||
|
// Valid durations should succeed
|
||||||
|
assert!(timer.start(1).is_ok());
|
||||||
|
assert!(timer.start(SleepTimer::MAX_DURATION).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_expiration() {
|
||||||
|
let mut timer = SleepTimer::new();
|
||||||
|
timer.start(1).unwrap();
|
||||||
|
assert!(timer.is_active());
|
||||||
|
assert!(!timer.is_expired());
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(1100));
|
||||||
|
|
||||||
|
assert!(timer.is_expired());
|
||||||
|
assert_eq!(timer.remaining_seconds().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_update() {
|
||||||
|
let mut timer = SleepTimer::new();
|
||||||
|
timer.start(60).unwrap();
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
|
||||||
|
// Update should reset the timer
|
||||||
|
timer.update(30).unwrap();
|
||||||
|
assert_eq!(timer.duration_seconds(), 30);
|
||||||
|
|
||||||
|
let remaining = timer.remaining_seconds().unwrap();
|
||||||
|
assert!(remaining <= 30 && remaining > 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timer_cancel() {
|
||||||
|
let mut timer = SleepTimer::new();
|
||||||
|
timer.start(60).unwrap();
|
||||||
|
assert!(timer.is_active());
|
||||||
|
|
||||||
|
timer.cancel();
|
||||||
|
assert!(!timer.is_active());
|
||||||
|
assert_eq!(timer.remaining_seconds(), None);
|
||||||
|
assert_eq!(timer.duration_seconds(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
|
use crate::model::PlaybackState;
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||||
VolumeControl,
|
TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use crate::model::PlaybackState;
|
use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||||
use crate::upnp_clients::{
|
use crate::upnp_clients::{
|
||||||
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||||
RenderingControlClient,
|
RenderingControlClient,
|
||||||
@@ -19,6 +22,7 @@ pub struct UpnpRenderer {
|
|||||||
rendering_control: Option<RenderingControlClient>,
|
rendering_control: Option<RenderingControlClient>,
|
||||||
connection_manager: Option<ConnectionManagerClient>,
|
connection_manager: Option<ConnectionManagerClient>,
|
||||||
has_avtransport_set_next: bool,
|
has_avtransport_set_next: bool,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpRenderer {
|
impl UpnpRenderer {
|
||||||
@@ -85,6 +89,24 @@ impl UpnpRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UpnpRenderer {
|
||||||
|
pub fn new(
|
||||||
|
avtransport: Option<AvTransportClient>,
|
||||||
|
rendering_control: Option<RenderingControlClient>,
|
||||||
|
connection_manager: Option<ConnectionManagerClient>,
|
||||||
|
has_avtransport_set_next: bool,
|
||||||
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
avtransport,
|
||||||
|
rendering_control,
|
||||||
|
connection_manager,
|
||||||
|
has_avtransport_set_next,
|
||||||
|
queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
// Prepare le service AVTTransport
|
// Prepare le service AVTTransport
|
||||||
@@ -122,11 +144,15 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the internal queue
|
||||||
|
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
avtransport,
|
avtransport,
|
||||||
rendering_control,
|
rendering_control,
|
||||||
connection_manager,
|
connection_manager,
|
||||||
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||||
|
queue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +161,152 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl RendererBackend for UpnpRenderer {
|
||||||
|
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
|
||||||
|
&self.queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueTransportControl for UpnpRenderer {
|
||||||
|
fn play_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
|
||||||
|
// Get or initialize current index
|
||||||
|
let current_index = match queue.current_index()? {
|
||||||
|
Some(idx) => idx,
|
||||||
|
None => {
|
||||||
|
if queue.len()? > 0 {
|
||||||
|
queue.set_index(Some(0))?;
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
return Err(ControlPointError::QueueError("Queue is empty".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the item
|
||||||
|
let item = queue
|
||||||
|
.get_item(current_index)?
|
||||||
|
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
|
||||||
|
|
||||||
|
drop(queue);
|
||||||
|
|
||||||
|
// Build metadata - handle optional TrackMetadata
|
||||||
|
let metadata = if let Some(ref track_metadata) = item.metadata {
|
||||||
|
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
|
||||||
|
} else {
|
||||||
|
// Fallback to minimal DIDL-Lite if no metadata
|
||||||
|
format!(
|
||||||
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
|
||||||
|
item.protocol_info, item.uri
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// UPNP: SetAVTransportURI + Play
|
||||||
|
let avt = self.avtransport()?;
|
||||||
|
avt.set_av_transport_uri(&item.uri, &metadata)?;
|
||||||
|
avt.play(0, "1")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.advance()? {
|
||||||
|
return Err(ControlPointError::QueueError("No next track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_previous(&self) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
if !queue.rewind()? {
|
||||||
|
return Err(ControlPointError::QueueError("No previous track".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||||
|
{
|
||||||
|
let mut queue = self.queue.lock().unwrap();
|
||||||
|
queue.set_index(Some(index))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.play_from_queue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueueBackend for UpnpRenderer {
|
||||||
|
fn len(&self) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().track_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().id_to_position(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().position_to_id(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().current_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().queue_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().set_index(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_queue(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().sync_queue(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().get_item(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().replace_item(index, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_items(
|
||||||
|
&mut self,
|
||||||
|
items: Vec<PlaybackItem>,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
self.queue.lock().unwrap().enqueue_items(items, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||||
///
|
///
|
||||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||||
|
|||||||
@@ -282,6 +282,26 @@ pub struct TransferQueueRequest {
|
|||||||
pub destination_renderer_id: String,
|
pub destination_renderer_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Requête pour démarrer ou mettre à jour le sleep timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
|
pub struct SleepTimerRequest {
|
||||||
|
/// Durée en secondes (maximum 7200 = 2 heures)
|
||||||
|
pub duration_seconds: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// État du sleep timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct SleepTimerState {
|
||||||
|
/// Timer actif ou non
|
||||||
|
pub active: bool,
|
||||||
|
/// Durée totale configurée en secondes
|
||||||
|
pub duration_seconds: u32,
|
||||||
|
/// Secondes restantes (None si timer inactif)
|
||||||
|
pub remaining_seconds: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Réponse générique de succès
|
/// Réponse générique de succès
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
@@ -426,6 +446,10 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
crate::pmoserver_ext::volume_up_renderer,
|
crate::pmoserver_ext::volume_up_renderer,
|
||||||
crate::pmoserver_ext::volume_down_renderer,
|
crate::pmoserver_ext::volume_down_renderer,
|
||||||
crate::pmoserver_ext::toggle_mute_renderer,
|
crate::pmoserver_ext::toggle_mute_renderer,
|
||||||
|
crate::pmoserver_ext::start_sleep_timer,
|
||||||
|
crate::pmoserver_ext::update_sleep_timer,
|
||||||
|
crate::pmoserver_ext::cancel_sleep_timer,
|
||||||
|
crate::pmoserver_ext::get_sleep_timer_state,
|
||||||
crate::pmoserver_ext::attach_playlist_binding,
|
crate::pmoserver_ext::attach_playlist_binding,
|
||||||
crate::pmoserver_ext::detach_playlist_binding,
|
crate::pmoserver_ext::detach_playlist_binding,
|
||||||
crate::pmoserver_ext::play_content,
|
crate::pmoserver_ext::play_content,
|
||||||
@@ -456,6 +480,8 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
SeekQueueRequest,
|
SeekQueueRequest,
|
||||||
SeekRequest,
|
SeekRequest,
|
||||||
TransferQueueRequest,
|
TransferQueueRequest,
|
||||||
|
SleepTimerRequest,
|
||||||
|
SleepTimerState,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ use crate::openapi::{
|
|||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||||
SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
SeekQueueRequest, SeekRequest, SleepTimerRequest, SleepTimerState, SuccessResponse,
|
||||||
|
TransferQueueRequest, VolumeSetRequest,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::queue::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
@@ -1133,6 +1134,215 @@ async fn toggle_mute_renderer(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HANDLERS - SLEEP TIMER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/timer/start - Démarre le sleep timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/timer/start",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = SleepTimerRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Timer démarré", body = SleepTimerState),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 400, description = "Durée invalide", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn start_sleep_timer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<SleepTimerRequest>,
|
||||||
|
) -> Result<Json<SleepTimerState>, (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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Start the timer
|
||||||
|
let remaining = renderer
|
||||||
|
.start_sleep_timer(req.duration_seconds)
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(
|
||||||
|
"Failed to start sleep timer for renderer {}: {}",
|
||||||
|
renderer_id, e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to start timer: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Note: TimerStarted event will be emitted by the timer watchdog thread
|
||||||
|
|
||||||
|
Ok(Json(SleepTimerState {
|
||||||
|
active: true,
|
||||||
|
duration_seconds: req.duration_seconds,
|
||||||
|
remaining_seconds: Some(remaining),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/timer/update - Met à jour la durée du timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/timer/update",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
request_body = SleepTimerRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Timer mis à jour", body = SleepTimerState),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
|
(status = 400, description = "Durée invalide", body = ErrorResponse),
|
||||||
|
(status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn update_sleep_timer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
Json(req): Json<SleepTimerRequest>,
|
||||||
|
) -> Result<Json<SleepTimerState>, (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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Update the timer
|
||||||
|
let remaining = renderer
|
||||||
|
.update_sleep_timer(req.duration_seconds)
|
||||||
|
.map_err(|e| {
|
||||||
|
warn!(
|
||||||
|
"Failed to update sleep timer for renderer {}: {}",
|
||||||
|
renderer_id, e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Failed to update timer: {}", e),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Note: TimerUpdated event will be emitted by the timer watchdog thread
|
||||||
|
|
||||||
|
Ok(Json(SleepTimerState {
|
||||||
|
active: true,
|
||||||
|
duration_seconds: req.duration_seconds,
|
||||||
|
remaining_seconds: Some(remaining),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /control/renderers/{renderer_id}/timer/cancel - Annule le sleep timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/renderers/{renderer_id}/timer/cancel",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Timer annulé", body = SuccessResponse),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn cancel_sleep_timer(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> 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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Cancel the timer
|
||||||
|
renderer.cancel_sleep_timer();
|
||||||
|
|
||||||
|
// Note: TimerCancelled event will be emitted by the timer watchdog thread
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse {
|
||||||
|
message: "Sleep timer cancelled".to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /control/renderers/{renderer_id}/timer - Récupère l'état du sleep timer
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/renderers/{renderer_id}/timer",
|
||||||
|
params(
|
||||||
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "État du timer", body = SleepTimerState),
|
||||||
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
|
),
|
||||||
|
tag = "control"
|
||||||
|
)]
|
||||||
|
async fn get_sleep_timer_state(
|
||||||
|
State(state): State<ControlPointState>,
|
||||||
|
Path(renderer_id): Path<String>,
|
||||||
|
) -> Result<Json<SleepTimerState>, (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 (active, duration_seconds, remaining_seconds) = renderer.sleep_timer_state();
|
||||||
|
|
||||||
|
Ok(Json(SleepTimerState {
|
||||||
|
active,
|
||||||
|
duration_seconds,
|
||||||
|
remaining_seconds,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HANDLERS - BINDING PLAYLIST
|
// HANDLERS - BINDING PLAYLIST
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1987,6 +2197,20 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
"/renderers/{renderer_id}/mute/toggle",
|
"/renderers/{renderer_id}/mute/toggle",
|
||||||
post(toggle_mute_renderer),
|
post(toggle_mute_renderer),
|
||||||
)
|
)
|
||||||
|
// Sleep timer
|
||||||
|
.route("/renderers/{renderer_id}/timer", get(get_sleep_timer_state))
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/timer/start",
|
||||||
|
post(start_sleep_timer),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/timer/update",
|
||||||
|
post(update_sleep_timer),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/timer/cancel",
|
||||||
|
post(cancel_sleep_timer),
|
||||||
|
)
|
||||||
// Playlist binding
|
// Playlist binding
|
||||||
.route(
|
.route(
|
||||||
"/renderers/{renderer_id}/binding/attach",
|
"/renderers/{renderer_id}/binding/attach",
|
||||||
|
|||||||
@@ -85,6 +85,31 @@ pub enum RendererEventPayload {
|
|||||||
container_id: Option<String>,
|
container_id: Option<String>,
|
||||||
timestamp: chrono::DateTime<chrono::Utc>,
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
},
|
},
|
||||||
|
TimerStarted {
|
||||||
|
renderer_id: String,
|
||||||
|
duration_seconds: u32,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
TimerUpdated {
|
||||||
|
renderer_id: String,
|
||||||
|
duration_seconds: u32,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
TimerTick {
|
||||||
|
renderer_id: String,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
TimerExpired {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
|
TimerCancelled {
|
||||||
|
renderer_id: String,
|
||||||
|
timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
},
|
||||||
Online {
|
Online {
|
||||||
renderer_id: String,
|
renderer_id: String,
|
||||||
friendly_name: String,
|
friendly_name: String,
|
||||||
@@ -273,6 +298,41 @@ pub async fn renderer_events_sse(
|
|||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerStarted {
|
||||||
|
renderer_id: id.0,
|
||||||
|
duration_seconds,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerUpdated {
|
||||||
|
renderer_id: id.0,
|
||||||
|
duration_seconds,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerTick { id, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerTick {
|
||||||
|
renderer_id: id.0,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerExpired { id } => {
|
||||||
|
RendererEventPayload::TimerExpired {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerCancelled { id } => {
|
||||||
|
RendererEventPayload::TimerCancelled {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
RendererEvent::Online { id, info } => {
|
RendererEvent::Online { id, info } => {
|
||||||
RendererEventPayload::Online {
|
RendererEventPayload::Online {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
@@ -647,6 +707,41 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
|||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerStarted {
|
||||||
|
renderer_id: id.0,
|
||||||
|
duration_seconds,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerUpdated {
|
||||||
|
renderer_id: id.0,
|
||||||
|
duration_seconds,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerTick { id, remaining_seconds } => {
|
||||||
|
RendererEventPayload::TimerTick {
|
||||||
|
renderer_id: id.0,
|
||||||
|
remaining_seconds,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerExpired { id } => {
|
||||||
|
RendererEventPayload::TimerExpired {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RendererEvent::TimerCancelled { id } => {
|
||||||
|
RendererEventPayload::TimerCancelled {
|
||||||
|
renderer_id: id.0,
|
||||||
|
timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
RendererEvent::Online { id, info } => {
|
RendererEvent::Online { id, info } => {
|
||||||
RendererEventPayload::Online {
|
RendererEventPayload::Online {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
|
|||||||
@@ -276,7 +276,8 @@ impl OhPlaylistClient {
|
|||||||
|
|
||||||
let envelope = ensure_success("Id", &call_result)?;
|
let envelope = ensure_success("Id", &call_result)?;
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| {
|
let response =
|
||||||
|
find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| {
|
||||||
ControlPointError::OpenHomeError(format!(
|
ControlPointError::OpenHomeError(format!(
|
||||||
"Missing ReadResponse element in SOAP body"
|
"Missing ReadResponse element in SOAP body"
|
||||||
))
|
))
|
||||||
@@ -316,7 +317,7 @@ impl OhPlaylistClient {
|
|||||||
|
|
||||||
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
|
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
|
||||||
let second_str = second.to_string();
|
let second_str = second.to_string();
|
||||||
let args = [("Second", second_str.as_str())];
|
let args = [("Value", second_str.as_str())];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result = invoke_upnp_action(
|
||||||
&self.control_url,
|
&self.control_url,
|
||||||
&self.service_type,
|
&self.service_type,
|
||||||
|
|||||||
Reference in New Issue
Block a user