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

@@ -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") {