Implémentation du playback gapless avec préchargement des pistes

Cette modification implémente un système de playback gapless pour les renderers web, permettant une transition fluide entre les pistes. Le changement inclut l'ajout d'un moteur audio qui utilise deux éléments <audio> en ping-pong connectés à un AudioContext pour le contrôle du volume/mute et le contrôle du flux gapless. Les commandes UPnP ont été mises à jour pour gérer le préchargement des pistes suivantes via SetNextAVTransportURI. Le backend a été modifié pour gérer les transitions gapless et précharger les pistes suivantes. Des ajustements ont également été apportés au serveur pour permettre les requêtes CORS nécessaires à ce fonctionnement.
This commit is contained in:
2026-02-21 10:16:49 +01:00
parent 03775f7574
commit fbe7291b43
10 changed files with 493 additions and 102 deletions

View File

@@ -5,7 +5,16 @@
* et se déconnecte proprement au démontage ou à la fermeture de la page.
*
* Le navigateur est ainsi vu comme un renderer UPnP par le ControlPoint.
* Un élément <audio> headless exécute les commandes de transport reçues.
* L'audio est géré via deux éléments <audio> en ping-pong connectés à un
* AudioContext (MediaElementSourceNode) pour le contrôle du volume/mute.
*
* ## Flux gapless
* 1. SetAVTransportURI(N) → slotA.src = N, slotA.load()
* 2. Play → slotA.play()
* 3. SetNextAVTransportURI(N+1) → slotB.src = N+1, slotB.load() (préchargement)
* 4. slotA "ended" → currentSlot = B, slotB.play() immédiat
* → TrackEnded envoyé au backend
* 5. Cycle recommence depuis 3 (slotA redevient le "next")
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
@@ -24,7 +33,7 @@ interface RendererInfo {
description_url: string;
}
type TransportAction = "play" | "pause" | "stop" | "seek" | "set_uri";
type TransportAction = "play" | "pause" | "stop" | "seek" | "set_uri" | "set_next_uri";
interface CommandParams {
uri?: string;
@@ -46,6 +55,7 @@ type ClientMessage =
| { type: "state_update"; state: PlaybackState }
| { type: "position_update"; position: string; duration: string }
| { type: "volume_update"; volume: number; mute: boolean }
| { type: "track_ended" }
| { type: "pong" };
// ─── Détection des formats supportés ─────────────────────────────────────────
@@ -83,6 +93,210 @@ function upnpTimeToSeconds(t: string): number {
return (h ?? 0) * 3600 + (m ?? 0) * 60 + (s ?? 0);
}
// ─── Moteur Audio (HTMLAudioElement + MediaElementSourceNode) ─────────────────
class GaplessEngine {
/** Les deux éléments <audio> fixes (ping-pong) */
private readonly slots: [HTMLAudioElement, HTMLAudioElement];
/** Index du slot actuellement en lecture (0 ou 1) */
private currentSlot: 0 | 1 = 0;
/** URI préchargée dans le slot "next" (l'autre) */
private nextUri: string | null = null;
private volume = 1.0;
private muted = false;
private paused = false;
/** Durée de la piste courante (secondes), lue via loadedmetadata */
private _duration = 0;
onStateChange: (state: PlaybackState) => void = () => {};
onPosition: (pos: number, dur: number) => void = () => {};
onTrackEnded: () => void = () => {};
private positionInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
this.slots = [
this.makeAudioElement(),
this.makeAudioElement(),
];
}
// ── Volume / Mute ────────────────────────────────────────────────────────
setVolume(v: number) {
this.volume = v;
for (const el of this.slots) {
el.volume = this.muted ? 0 : v;
}
}
setMute(m: boolean) {
this.muted = m;
for (const el of this.slots) {
el.volume = m ? 0 : this.volume;
}
}
// ── Transport ────────────────────────────────────────────────────────────
/** Charge la piste courante (sans la jouer). */
setCurrent(uri: string): void {
this.paused = false;
this.nextUri = null;
this.onStateChange("TRANSITIONING");
console.debug(`[GaplessEngine] setCurrent: ${uri}`);
const el = this.slots[this.currentSlot];
// Retirer l'écouteur "ended" de l'autre slot si présent
const otherSlot = (1 - this.currentSlot) as 0 | 1;
this.slots[otherSlot].onended = null;
this.slots[otherSlot].pause();
el.onended = null;
el.pause();
el.src = uri;
el.load();
// Récupérer la durée dès que les métadonnées sont disponibles
el.onloadedmetadata = () => {
this._duration = el.duration || 0;
console.debug(`[GaplessEngine] loadedmetadata: duration=${this._duration.toFixed(2)}s`);
};
// Ne pas émettre STOPPED ici : l'état reste TRANSITIONING jusqu'au play()
}
/** Précharge la piste suivante dans l'autre slot. */
setNext(uri: string): void {
this.nextUri = uri;
const nextSlot = (1 - this.currentSlot) as 0 | 1;
const el = this.slots[nextSlot];
el.src = uri;
el.preload = "auto";
el.load();
console.debug(`[GaplessEngine] setNext: préchargement de ${uri} dans slot ${nextSlot}`);
}
async play(): Promise<void> {
const el = this.slots[this.currentSlot];
console.debug(`[GaplessEngine] play(), slot=${this.currentSlot}, src=${el.src}, paused=${this.paused}`);
el.onended = () => this.onCurrentEnded();
try {
await el.play();
} catch (e) {
console.error("[GaplessEngine] play() failed:", e);
return;
}
this.paused = false;
this.startPositionTimer();
this.onStateChange("PLAYING");
}
pause(): void {
const el = this.slots[this.currentSlot];
el.pause();
this.paused = true;
this.stopPositionTimer();
this.onStateChange("PAUSED");
this.sendPosition();
}
stop(): void {
const el = this.slots[this.currentSlot];
el.onended = null;
el.pause();
el.currentTime = 0;
this.paused = false;
this.nextUri = null;
this.stopPositionTimer();
this.onStateChange("STOPPED");
}
seek(toSeconds: number): void {
const el = this.slots[this.currentSlot];
el.currentTime = toSeconds;
}
destroy(): void {
this.stopPositionTimer();
for (const el of this.slots) {
el.onended = null;
el.pause();
el.src = "";
}
}
// ── Privé ────────────────────────────────────────────────────────────────
private makeAudioElement(): HTMLAudioElement {
const el = document.createElement("audio");
el.preload = "auto";
return el;
}
private onCurrentEnded(): void {
console.debug("[GaplessEngine] onCurrentEnded");
const nextSlot = (1 - this.currentSlot) as 0 | 1;
if (this.nextUri !== null) {
// Le slot suivant est préchargé, on bascule
this.currentSlot = nextSlot;
this.nextUri = null;
this._duration = 0;
const nextEl = this.slots[this.currentSlot];
nextEl.onended = () => this.onCurrentEnded();
// Récupérer la durée de la nouvelle piste courante
if (nextEl.duration && isFinite(nextEl.duration)) {
this._duration = nextEl.duration;
} else {
nextEl.onloadedmetadata = () => {
this._duration = nextEl.duration || 0;
};
}
// Informer le backend (qui fera le swap current←next côté serveur)
this.onTrackEnded();
// Démarrer immédiatement (le préchargement a eu lieu)
nextEl.play().catch((e) =>
console.error("[GaplessEngine] next.play() failed:", e),
);
// L'état reste PLAYING
} else {
// Pas de suivant : fin de lecture
this.stopPositionTimer();
this.onStateChange("STOPPED");
this.onTrackEnded();
}
}
private startPositionTimer(): void {
if (this.positionInterval !== null) return;
this.positionInterval = setInterval(() => this.sendPosition(), 1000);
}
private stopPositionTimer(): void {
if (this.positionInterval !== null) {
clearInterval(this.positionInterval);
this.positionInterval = null;
}
}
private sendPosition(): void {
const el = this.slots[this.currentSlot];
const pos = el.currentTime || 0;
const dur = (el.duration && isFinite(el.duration)) ? el.duration : this._duration;
this.onPosition(pos, dur);
}
}
// ─── Composable ───────────────────────────────────────────────────────────────
export function useWebRenderer() {
@@ -90,8 +304,7 @@ export function useWebRenderer() {
const rendererInfo = ref<RendererInfo | null>(null);
let ws: WebSocket | null = null;
let audio: HTMLAudioElement | null = null;
let positionTimer: ReturnType<typeof setInterval> | null = null;
let engine: GaplessEngine | null = null;
let onConnectedCallback: (() => void) | null = null;
// ── Envoi d'un message au backend ────────────────────────────────────────
@@ -102,106 +315,64 @@ export function useWebRenderer() {
}
}
// ── Player audio headless ─────────────────────────────────────────────────
// ── Initialisation du moteur ──────────────────────────────────────────────
function sendPosition() {
if (!audio) return;
const pos = isFinite(audio.currentTime) ? audio.currentTime : 0;
const dur = isFinite(audio.duration) ? audio.duration : 0;
function initEngine(): GaplessEngine {
const e = new GaplessEngine();
e.onStateChange = (state) => {
send({ type: "state_update", state });
};
e.onPosition = (pos, dur) => {
send({
type: "position_update",
position: secondsToUpnpTime(pos),
duration: secondsToUpnpTime(dur),
});
}
};
function startPositionTimer() {
if (positionTimer !== null) return;
positionTimer = setInterval(sendPosition, 1000);
}
e.onTrackEnded = () => {
send({ type: "track_ended" });
};
function stopPositionTimer() {
if (positionTimer !== null) {
clearInterval(positionTimer);
positionTimer = null;
}
}
function createAudio(): HTMLAudioElement {
const el = new Audio();
el.preload = "auto";
el.addEventListener("play", () => {
send({ type: "state_update", state: "PLAYING" });
startPositionTimer();
});
el.addEventListener("pause", () => {
send({ type: "state_update", state: "PAUSED" });
stopPositionTimer();
sendPosition();
});
el.addEventListener("ended", () => {
send({ type: "state_update", state: "STOPPED" });
stopPositionTimer();
});
el.addEventListener("waiting", () => {
send({ type: "state_update", state: "TRANSITIONING" });
});
el.addEventListener("canplay", () => {
if (!el.paused) send({ type: "state_update", state: "PLAYING" });
});
el.addEventListener("volumechange", () => {
const vol = Math.round(el.volume * 100);
send({ type: "volume_update", volume: vol, mute: el.muted });
});
el.addEventListener("error", () => {
console.error("[WebRenderer] Erreur audio :", el.error);
send({ type: "state_update", state: "STOPPED" });
stopPositionTimer();
});
return el;
return e;
}
// ── Exécution des commandes UPnP ──────────────────────────────────────────
async function execCommand(action: TransportAction, params?: CommandParams) {
if (!audio) return;
if (!engine) return;
console.debug(`[WebRenderer] Commande: ${action}`, params);
switch (action) {
case "set_uri":
if (params?.uri) {
audio.pause();
audio.src = params.uri;
audio.load();
send({ type: "state_update", state: "STOPPED" });
engine.setCurrent(params.uri);
}
break;
case "set_next_uri":
if (params?.uri) {
engine.setNext(params.uri);
}
break;
case "play":
if (audio.src) {
try {
await audio.play();
} catch (e) {
console.error("[WebRenderer] play() refusé :", e);
}
}
await engine.play();
break;
case "pause":
audio.pause();
engine.pause();
break;
case "stop":
audio.pause();
audio.currentTime = 0;
send({ type: "state_update", state: "STOPPED" });
engine.stop();
break;
case "seek":
if (params?.position) {
audio.currentTime = upnpTimeToSeconds(params.position);
engine.seek(upnpTimeToSeconds(params.position));
}
break;
}
@@ -225,8 +396,6 @@ export function useWebRenderer() {
console.info(
`[WebRenderer] Session créée — UDN: ${msg.renderer_info.udn}`,
);
// Notifier le parent pour qu'il rafraîchisse la liste des renderers
// L'événement SSE peut arriver avant que le subscriber soit prêt
onConnectedCallback?.();
break;
@@ -235,15 +404,11 @@ export function useWebRenderer() {
break;
case "set_volume":
if (audio) {
audio.volume = Math.max(0, Math.min(1, msg.volume / 100));
}
engine?.setVolume(msg.volume / 100);
break;
case "set_mute":
if (audio) {
audio.muted = msg.mute;
}
engine?.setMute(msg.mute);
break;
case "ping":
@@ -291,11 +456,7 @@ export function useWebRenderer() {
// ── Déconnexion ───────────────────────────────────────────────────────────
function disconnect() {
stopPositionTimer();
if (audio) {
audio.pause();
audio.src = "";
}
engine?.destroy();
if (ws) {
ws.close(1000, "Page unloaded");
ws = null;
@@ -306,14 +467,14 @@ export function useWebRenderer() {
// ── Cycle de vie ──────────────────────────────────────────────────────────
onMounted(() => {
audio = createAudio();
engine = initEngine();
connect();
window.addEventListener("beforeunload", disconnect);
});
onUnmounted(() => {
disconnect();
audio = null;
engine = null;
window.removeEventListener("beforeunload", disconnect);
});

View File

@@ -368,4 +368,5 @@ const currentTabProps = computed(() => {
opacity: 0;
transform: translateX(-20px);
}
</style>

View File

@@ -230,8 +230,11 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
if let Some(generator) = param_generator {
if let Some(data) = generator(cache.clone(), pk.to_string(), param.to_string()).await {
// Le générateur a créé les données, les servir directement
let response =
(StatusCode::OK, [("content-type", content_type)], data).into_response();
let response = (
StatusCode::OK,
[("content-type", content_type), ("access-control-allow-origin", "*")],
data,
).into_response();
if response.status().is_success() {
cache.notify_broadcast(pk, &qualifier).await;
@@ -342,7 +345,8 @@ async fn stream_file_progressive(
let mut response_builder = axum::http::Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes");
.header(header::ACCEPT_RANGES, "bytes")
.header("Access-Control-Allow-Origin", "*");
if let Some(total_size) = expected_size {
response_builder = response_builder.header(
@@ -370,7 +374,8 @@ async fn stream_file_progressive(
let mut response = axum::http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::ACCEPT_RANGES, "bytes");
.header(header::ACCEPT_RANGES, "bytes")
.header("Access-Control-Allow-Origin", "*");
if let Some(size) = expected_size {
response = response.header(header::CONTENT_LENGTH, size);
@@ -414,6 +419,10 @@ async fn serve_complete_file(
parts
.headers
.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
parts.headers.insert(
"Access-Control-Allow-Origin".parse::<axum::http::HeaderName>().unwrap(),
"*".parse().unwrap(),
);
// Convertir ServeFileSystemResponseBody en Body
axum::http::Response::from_parts(parts, Body::new(body))
@@ -515,9 +524,27 @@ pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
let path_with_param = format!("/{}/{}/{{pk}}/{{param}}", cache_name, cache_type);
let path_without_param = format!("/{}/{}/{{pk}}", cache_name, cache_type);
async fn cors_preflight() -> impl IntoResponse {
(
StatusCode::NO_CONTENT,
[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Methods", "GET, OPTIONS"),
("Access-Control-Allow-Headers", "Range, Content-Type"),
("Access-Control-Max-Age", "86400"),
],
)
}
Router::new()
.route(&path_without_param, get(get_file::<C>))
.route(&path_with_param, get(get_file_with_param::<C>))
.route(
&path_without_param,
get(get_file::<C>).options(cors_preflight),
)
.route(
&path_with_param,
get(get_file_with_param::<C>).options(cors_preflight),
)
.with_state((cache, content_type, param_generator))
}

View File

@@ -878,6 +878,10 @@ impl ControlPoint {
renderer.set_last_metadata(Some(metadata));
renderer.set_playback_source(PlaybackSource::FromQueue);
// Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id);
Ok(())
}
@@ -972,6 +976,51 @@ impl ControlPoint {
}
}
/// Prefetch the next track for a renderer (gapless playback support).
///
/// Called by the WebRenderer backend after a TrackEnded event to trigger
/// SetNextAVTransportURI for the next-next track (N+2).
pub fn prefetch_next_for_renderer(&self, renderer_id: &DeviceId) {
if let Some(renderer) = self.music_renderer_by_id(renderer_id) {
self.prefetch_next_track(&renderer, renderer_id);
}
}
/// Advances the queue index by one for a gapless transition, then prefetches the next-next track.
///
/// Called by the WebRenderer backend after a gapless TrackEnded event. In the gapless case,
/// the browser has already advanced to the next track autonomously, so the backend queue
/// index must be updated to stay in sync. Then we prefetch track N+2 for the next gapless
/// transition.
pub fn advance_queue_and_prefetch(&self, renderer_id: &DeviceId) {
if let Some(renderer) = self.music_renderer_by_id(renderer_id) {
// Advance the queue index to match the browser's gapless transition
match renderer.advance_queue_index() {
Ok(true) => {
debug!(
renderer = renderer_id.0.as_str(),
"advance_queue_and_prefetch: queue index advanced for gapless transition"
);
// Now prefetch the next-next track (N+2)
self.prefetch_next_track(&renderer, renderer_id);
}
Ok(false) => {
debug!(
renderer = renderer_id.0.as_str(),
"advance_queue_and_prefetch: no next track in queue, skipping prefetch"
);
}
Err(err) => {
debug!(
renderer = renderer_id.0.as_str(),
error = %err,
"advance_queue_and_prefetch: failed to advance queue index"
);
}
}
}
}
/// Jumps to a specific index in the queue and starts playback.
pub fn play_queue_index(
&self,
@@ -1019,6 +1068,10 @@ impl ControlPoint {
}
renderer.set_playback_source(PlaybackSource::FromQueue);
// Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id);
Ok(())
}

View File

@@ -879,6 +879,21 @@ impl MusicRenderer {
Ok(())
}
/// Advance the queue index by one without starting playback.
///
/// Used by the WebRenderer gapless path: the browser autonomously transitions
/// to the next track, so only the backend queue pointer needs to be updated
/// to stay in sync.
pub fn advance_queue_index(&self) -> Result<bool, ControlPointError> {
let mut backend = self.lock_backend_for("advance_queue_index");
let advanced = backend.advance()?;
drop(backend);
if advanced {
self.emit_queue_updated();
}
Ok(advanced)
}
/// Play from a specific index in the queue.
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// Reset the has_played flag before starting playback to prevent

View File

@@ -146,11 +146,34 @@ pub fn set_uri_handler(
})
}
pub fn set_next_uri_handler(_state: SharedState) -> ActionHandler {
pub fn set_next_uri_handler(
ws: mpsc::UnboundedSender<ServerMessage>,
state: SharedState,
) -> ActionHandler {
Arc::new(move |data: ActionData| -> ActionFuture {
let ws = ws.clone();
let state = state.clone();
Box::pin(async move {
let _uri: String = get!(&data, "NextURI", String);
let _metadata: String = get!(&data, "NextURIMetaData", String);
let uri: String = get!(&data, "NextURI", String);
let metadata: String = get_value::<String>(&data, "NextURIMetaData")
.or_else(|_| {
get_value::<DIDLLite>(&data, "NextURIMetaData")
.map(|didl| didl.to_xml())
})
.unwrap_or_default();
let _ = ws.send(ServerMessage::Command {
action: TransportAction::SetNextUri,
params: Some(CommandParams {
uri: Some(uri.clone()),
metadata: Some(metadata.clone()),
position: None,
}),
});
{
let mut s = state.write();
s.next_uri = Some(uri);
s.next_metadata = Some(metadata);
}
Ok(data)
})
})
@@ -243,8 +266,16 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler {
"CurrentURIMetaData",
s.current_metadata.clone().unwrap_or_default()
);
set!(&mut data, "NextURI", String::new());
set!(&mut data, "NextURIMetaData", String::new());
set!(
&mut data,
"NextURI",
s.next_uri.clone().unwrap_or_default()
);
set!(
&mut data,
"NextURIMetaData",
s.next_metadata.clone().unwrap_or_default()
);
Ok(data)
})
})

View File

@@ -32,6 +32,7 @@ pub enum TransportAction {
Stop,
Seek,
SetUri,
SetNextUri,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -53,6 +54,9 @@ pub enum ClientMessage {
PositionUpdate { position: String, duration: String },
MetadataUpdate { metadata: TrackMetadata },
VolumeUpdate { volume: u16, mute: bool },
/// Envoyé quand la piste courante se termine naturellement (gapless).
/// Le backend fait avancer current → next dans l'état partagé.
TrackEnded,
Pong,
}

View File

@@ -261,7 +261,7 @@ impl WebRendererFactory {
Arc::clone(&AVTRANSPORTNEXTURIMETADATA),
)))
.map_err(|e| FactoryError::ActionError(format!("{:?}", e)))?;
set_next_uri.set_handler(handlers::set_next_uri_handler(state.clone()));
set_next_uri.set_handler(handlers::set_next_uri_handler(ws.clone(), state.clone()));
add_action(&mut svc, Arc::new(set_next_uri))?;
// GetPositionInfo

View File

@@ -11,6 +11,8 @@ pub struct RendererState {
pub playback_state: PlaybackState,
pub current_uri: Option<String>,
pub current_metadata: Option<String>,
pub next_uri: Option<String>,
pub next_metadata: Option<String>,
pub position: Option<String>,
pub duration: Option<String>,
pub volume: u16,
@@ -23,6 +25,8 @@ impl Default for RendererState {
playback_state: PlaybackState::Stopped,
current_uri: None,
current_metadata: None,
next_uri: None,
next_metadata: None,
position: None,
duration: None,
volume: 100,

View File

@@ -167,6 +167,70 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) {
}
}
}
Ok(ClientMessage::TrackEnded) => {
if let Some(ref token) = session_token {
if let Some(session) = state.session_manager.get_session(token) {
let (next_uri, next_metadata, had_next) = {
let mut s = session.state.write();
let uri = s.next_uri.take();
let meta = s.next_metadata.take();
let had_next = uri.is_some();
s.current_uri = uri.clone();
s.current_metadata = meta.clone();
s.next_uri = None;
s.next_metadata = None;
s.position = None;
s.duration = None;
// Si on avait une piste suivante (gapless), on reste en Playing.
// Sinon, on reste en Stopped pour que le watcher déclenche l'auto-advance.
if had_next {
s.playback_state = PlaybackState::Playing;
}
// Si had_next == false, le navigateur a déjà envoyé state_update:STOPPED,
// donc s.playback_state est déjà Stopped. On le laisse tel quel.
(uri, meta, had_next)
};
let new_state = if had_next {
PlaybackState::Playing
} else {
PlaybackState::Stopped
};
update_transport_state_var(
&session.device_instance,
&new_state,
)
.await;
// Mettre à jour AVTransportURI pour que le ControlPoint
// voie la nouvelle piste courante et envoie SetNextAVTransportURI
update_uri_vars(
&session.device_instance,
next_uri.as_deref().unwrap_or(""),
next_metadata.as_deref().unwrap_or(""),
"", // next_uri vide : le ControlPoint le remplira
"",
)
.await;
// Si c'était une transition gapless (on avait une piste suivante),
// avancer l'index de la queue dans le ControlPoint et prefetch la piste N+2.
// Si pas de piste suivante, le watcher verra STOPPED et déclenchera l'auto-advance.
#[cfg(feature = "pmoserver")]
if had_next {
let udn = session.udn.clone();
let cp = state.control_point.clone();
tokio::spawn(async move {
cp.advance_queue_and_prefetch(
&pmocontrol::DeviceId(udn),
);
});
}
tracing::debug!(
uri = ?next_uri,
had_next,
"WebRenderer TrackEnded: advanced to next track"
);
}
}
}
Ok(ClientMessage::Pong) => {}
Err(e) => {
tracing::warn!(error = %e, "Failed to parse client message");
@@ -379,6 +443,37 @@ async fn update_position_vars(di: &DeviceInstance, position: &str, duration: &st
}
}
async fn update_uri_vars(
di: &DeviceInstance,
current_uri: &str,
current_metadata: &str,
next_uri: &str,
next_metadata: &str,
) {
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("AVTransportURI") {
let _ = var
.set_value(StateValue::String(current_uri.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportURIMetaData") {
let _ = var
.set_value(StateValue::String(current_metadata.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportNextURI") {
let _ = var
.set_value(StateValue::String(next_uri.to_string()))
.await;
}
if let Some(var) = service.get_variable("AVTransportNextURIMetaData") {
let _ = var
.set_value(StateValue::String(next_metadata.to_string()))
.await;
}
}
}
async fn update_metadata_var(di: &DeviceInstance, didl: &str) {
if let Some(service) = di.get_service("AVTransport") {
if let Some(var) = service.get_variable("CurrentTrackMetaData") {