From a502e4fc3ebaa9adaf94b33e93e6d7e4df73d60b Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 21 Feb 2026 20:54:22 +0100 Subject: [PATCH] =?UTF-8?q?Impl=C3=A9mentation=20de=20la=20reconnexion=20s?= =?UTF-8?q?table=20avec=20synchronisation=20d'=C3=A9tat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cette mise à jour permet une reconnexion stable du renderer Web après un reload de page, en conservant l'état audio (URI, volume, lecture en cours). - Ajout d'un identifiant d'instance stable (UUID) dans le navigateur pour retrouver le même renderer UPnP - Implémentation d'un mécanisme de synchronisation d'état (StateSync) lors des reconnexions - Mise à jour du système de sender partagé (SharedSender) pour permettre le remplacement des connexions WebSocket - Correction des handlers UPnP pour utiliser le nouveau type SharedSender - Amélioration de la gestion des erreurs d'autoplay dans le moteur audio - Mise à jour du numéro de version vers 0.3.21 --- PMOMusic/Cargo.toml | 2 +- .../webapp/src/composables/useWebRenderer.ts | 114 +++++++++-- pmowebrenderer/src/handlers.rs | 59 ++---- pmowebrenderer/src/messages.rs | 15 ++ pmowebrenderer/src/renderer.rs | 31 +-- pmowebrenderer/src/session.rs | 36 +++- pmowebrenderer/src/state.rs | 34 +++- pmowebrenderer/src/websocket.rs | 186 ++++++++++++++---- version.txt | 2 +- 9 files changed, 373 insertions(+), 106 deletions(-) diff --git a/PMOMusic/Cargo.toml b/PMOMusic/Cargo.toml index 58f691a1..90c27e78 100644 --- a/PMOMusic/Cargo.toml +++ b/PMOMusic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "PMOMusic" -version = "0.3.20" +version = "0.3.21" edition = "2024" [dependencies] diff --git a/pmoapp/webapp/src/composables/useWebRenderer.ts b/pmoapp/webapp/src/composables/useWebRenderer.ts index b1913b5d..4c85c056 100644 --- a/pmoapp/webapp/src/composables/useWebRenderer.ts +++ b/pmoapp/webapp/src/composables/useWebRenderer.ts @@ -22,6 +22,7 @@ import { ref, onMounted, onUnmounted, readonly } from "vue"; // ─── Types (miroir de messages.rs) ──────────────────────────────────────────── interface BrowserCapabilities { + instance_id: string; user_agent: string; supported_formats: string[]; } @@ -41,8 +42,21 @@ interface CommandParams { position?: string; } +interface StateSyncMessage { + type: "state_sync"; + current_uri?: string; + current_metadata?: string; + next_uri?: string; + next_metadata?: string; + playback_state: PlaybackState; + position?: string; + volume: number; + mute: boolean; +} + type ServerMessage = | { type: "session_created"; token: string; renderer_info: RendererInfo } + | StateSyncMessage | { type: "command"; action: TransportAction; params?: CommandParams } | { type: "set_volume"; volume: number } | { type: "set_mute"; mute: boolean } @@ -58,6 +72,44 @@ type ClientMessage = | { type: "track_ended" } | { type: "pong" }; +// ─── Identifiant stable de l'instance navigateur ───────────────────────────── + +const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id"; + +/** + * Génère un UUID v4. Utilise crypto.randomUUID() si disponible (HTTPS/localhost), + * sinon fallback sur crypto.getRandomValues() (disponible partout, y compris HTTP). + */ +function generateUUID(): string { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Retourne un UUID stable pour cette instance navigateur. + * Généré une fois, persisté en localStorage, réutilisé entre les reloads. + */ +function getOrCreateInstanceId(): string { + try { + let id = localStorage.getItem(INSTANCE_ID_KEY); + if (!id) { + id = generateUUID(); + localStorage.setItem(INSTANCE_ID_KEY, id); + } + return id; + } catch { + // localStorage unavailable (private mode, etc.) → use a session-scoped UUID + return generateUUID(); + } +} + // ─── Détection des formats supportés ───────────────────────────────────────── function getSupportedFormats(): string[] { @@ -151,7 +203,41 @@ class GaplessEngine { setCurrent(uri: string): void { this.nextUri = null; this.onStateChange("TRANSITIONING"); + this._loadCurrent(uri); + // Si play() est arrivé avant set_uri (race condition), lancer la lecture maintenant + if (this.playPending) { + this.playPending = false; + this.play().catch((e) => + console.error("[GaplessEngine] deferred play() failed:", e), + ); + } + } + + /** + * Restaure l'état audio après un reload sans notifier le serveur de TRANSITIONING. + * Le serveur connaît déjà l'état ; on recharge juste l'audio localement. + * Si shouldPlay=true mais que l'autoplay est bloqué, on notifie PAUSED + * pour que l'interface puisse proposer un bouton Play fonctionnel. + */ + async syncRestore(currentUri: string, nextUri: string | undefined, shouldPlay: boolean): Promise { + this._loadCurrent(currentUri); + if (nextUri) { + this.setNext(nextUri); + } + if (shouldPlay) { + try { + await this.play(); + // play() a réussi : onStateChange("PLAYING") a déjà été appelé dans play() + } catch { + // Autoplay bloqué par le navigateur : signaler PAUSED au serveur + // L'audio est chargé, un clic Play suffira à démarrer + this.onStateChange("PAUSED"); + } + } + } + + private _loadCurrent(uri: string): void { 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; @@ -167,14 +253,6 @@ class GaplessEngine { el.onloadedmetadata = () => { this._duration = el.duration || 0; }; - - // Si play() est arrivé avant set_uri (race condition), lancer la lecture maintenant - if (this.playPending) { - this.playPending = false; - this.play().catch((e) => - console.error("[GaplessEngine] deferred play() failed:", e), - ); - } } /** Précharge la piste suivante dans l'autre slot. */ @@ -214,8 +292,8 @@ class GaplessEngine { try { await el.play(); } catch (e) { - console.error("[GaplessEngine] play() failed:", e); - return; + console.warn("[GaplessEngine] play() failed (autoplay blocked?):", e); + throw e; } this.startPositionTimer(); @@ -381,7 +459,9 @@ export function useWebRenderer() { break; case "play": - await engine.play(); + await engine.play().catch((e) => + console.warn("[WebRenderer] play() failed:", e), + ); break; case "pause": @@ -418,6 +498,17 @@ export function useWebRenderer() { onConnectedCallback?.(); break; + case "state_sync": + if (engine && msg.current_uri) { + engine.setVolume(msg.volume / 100); + engine.setMute(msg.mute); + const shouldPlay = msg.playback_state === "PLAYING" || msg.playback_state === "TRANSITIONING"; + // syncRestore recharge l'audio sans notifier le serveur de l'état + // (le serveur connaît déjà l'état ; on évite un aller-retour TRANSITIONING) + engine.syncRestore(msg.current_uri, msg.next_uri, shouldPlay); + } + break; + case "command": void execCommand(msg.action, msg.params); break; @@ -449,6 +540,7 @@ export function useWebRenderer() { send({ type: "init", capabilities: { + instance_id: getOrCreateInstanceId(), user_agent: navigator.userAgent, supported_formats: getSupportedFormats(), }, diff --git a/pmowebrenderer/src/handlers.rs b/pmowebrenderer/src/handlers.rs index 82afe8d5..4039573e 100644 --- a/pmowebrenderer/src/handlers.rs +++ b/pmowebrenderer/src/handlers.rs @@ -4,7 +4,6 @@ //! envoyée au navigateur, ou lit l'état partagé pour les requêtes GET. use std::sync::Arc; -use tokio::sync::mpsc; use pmodidl::DIDLLite; use pmoupnp::actions::{ActionData, ActionError, ActionHandler, get_value}; @@ -12,19 +11,19 @@ use pmoupnp::{get, set}; use pmoutils::ToXmlElement; use crate::messages::{CommandParams, PlaybackState, ServerMessage, TransportAction}; -use crate::state::SharedState; +use crate::state::{SharedSender, SharedState}; type ActionFuture = std::pin::Pin> + Send>>; // ─── AVTransport Handlers ─────────────────────────────────────────────────── -pub fn play_handler(ws: mpsc::UnboundedSender, state: SharedState) -> ActionHandler { +pub fn play_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); Box::pin(async move { - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Play, params: None, }); @@ -34,12 +33,12 @@ pub fn play_handler(ws: mpsc::UnboundedSender, state: SharedState }) } -pub fn stop_handler(ws: mpsc::UnboundedSender, state: SharedState) -> ActionHandler { +pub fn stop_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); Box::pin(async move { - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Stop, params: None, }); @@ -49,15 +48,12 @@ pub fn stop_handler(ws: mpsc::UnboundedSender, state: SharedState }) } -pub fn pause_handler( - ws: mpsc::UnboundedSender, - state: SharedState, -) -> ActionHandler { +pub fn pause_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); Box::pin(async move { - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Pause, params: None, }); @@ -67,11 +63,11 @@ pub fn pause_handler( }) } -pub fn next_handler(ws: mpsc::UnboundedSender) -> ActionHandler { +pub fn next_handler(ws: SharedSender) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); Box::pin(async move { - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Play, params: None, }); @@ -80,11 +76,11 @@ pub fn next_handler(ws: mpsc::UnboundedSender) -> ActionHandler { }) } -pub fn previous_handler(ws: mpsc::UnboundedSender) -> ActionHandler { +pub fn previous_handler(ws: SharedSender) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); Box::pin(async move { - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Play, params: None, }); @@ -93,12 +89,12 @@ pub fn previous_handler(ws: mpsc::UnboundedSender) -> ActionHandl }) } -pub fn seek_handler(ws: mpsc::UnboundedSender) -> ActionHandler { +pub fn seek_handler(ws: SharedSender) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); Box::pin(async move { let target: String = get!(&data, "Target", String); - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::Seek, params: Some(CommandParams { uri: None, @@ -111,23 +107,19 @@ pub fn seek_handler(ws: mpsc::UnboundedSender) -> ActionHandler { }) } -pub fn set_uri_handler( - ws: mpsc::UnboundedSender, - state: SharedState, -) -> ActionHandler { +pub fn set_uri_handler(ws: SharedSender, 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, "CurrentURI", String); - // CurrentURIMetaData peut être String ou DIDLLite (après parsing) let metadata: String = get_value::(&data, "CurrentURIMetaData") .or_else(|_| { get_value::(&data, "CurrentURIMetaData") .map(|didl| didl.to_xml()) }) .unwrap_or_default(); - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::SetUri, params: Some(CommandParams { uri: Some(uri.clone()), @@ -146,10 +138,7 @@ pub fn set_uri_handler( }) } -pub fn set_next_uri_handler( - ws: mpsc::UnboundedSender, - state: SharedState, -) -> ActionHandler { +pub fn set_next_uri_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); @@ -161,7 +150,7 @@ pub fn set_next_uri_handler( .map(|didl| didl.to_xml()) }) .unwrap_or_default(); - let _ = ws.send(ServerMessage::Command { + ws.send(ServerMessage::Command { action: TransportAction::SetNextUri, params: Some(CommandParams { uri: Some(uri.clone()), @@ -283,16 +272,13 @@ pub fn get_media_info_handler(state: SharedState) -> ActionHandler { // ─── RenderingControl Handlers ────────────────────────────────────────────── -pub fn set_volume_handler( - ws: mpsc::UnboundedSender, - state: SharedState, -) -> ActionHandler { +pub fn set_volume_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); Box::pin(async move { let volume: u16 = get!(&data, "DesiredVolume", u16); - let _ = ws.send(ServerMessage::SetVolume { volume }); + ws.send(ServerMessage::SetVolume { volume }); state.write().volume = volume; Ok(data) }) @@ -311,16 +297,13 @@ pub fn get_volume_handler(state: SharedState) -> ActionHandler { }) } -pub fn set_mute_handler( - ws: mpsc::UnboundedSender, - state: SharedState, -) -> ActionHandler { +pub fn set_mute_handler(ws: SharedSender, state: SharedState) -> ActionHandler { Arc::new(move |data: ActionData| -> ActionFuture { let ws = ws.clone(); let state = state.clone(); Box::pin(async move { let mute: bool = get!(&data, "DesiredMute", bool); - let _ = ws.send(ServerMessage::SetMute { mute }); + ws.send(ServerMessage::SetMute { mute }); state.write().mute = mute; Ok(data) }) diff --git a/pmowebrenderer/src/messages.rs b/pmowebrenderer/src/messages.rs index 22ebfbcd..b8b07db8 100644 --- a/pmowebrenderer/src/messages.rs +++ b/pmowebrenderer/src/messages.rs @@ -10,6 +10,18 @@ pub enum ServerMessage { token: String, renderer_info: RendererInfo, }, + /// Envoyé après SessionCreated lors d'une reconnexion pour resynchroniser + /// l'état audio du navigateur (URI courante, état de lecture, etc.). + StateSync { + current_uri: Option, + current_metadata: Option, + next_uri: Option, + next_metadata: Option, + playback_state: PlaybackState, + position: Option, + volume: u16, + mute: bool, + }, Command { action: TransportAction, #[serde(skip_serializing_if = "Option::is_none")] @@ -62,6 +74,9 @@ pub enum ClientMessage { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowserCapabilities { + /// Identifiant stable de l'instance navigateur (UUID stocké en localStorage). + /// Permet de réutiliser le même renderer UPnP après un reload de page. + pub instance_id: String, pub user_agent: String, pub supported_formats: Vec, } diff --git a/pmowebrenderer/src/renderer.rs b/pmowebrenderer/src/renderer.rs index 5507de97..dd2e7a42 100644 --- a/pmowebrenderer/src/renderer.rs +++ b/pmowebrenderer/src/renderer.rs @@ -13,7 +13,7 @@ use pmoupnp::services::Service; use crate::handlers; use crate::messages::ServerMessage; -use crate::state::SharedState; +use crate::state::{SharedSender, SharedState}; // ─── Réimport des variables statiques de pmomediarenderer ─────────────────── // Variables AVTransport @@ -71,20 +71,25 @@ pub struct WebRendererFactory; impl WebRendererFactory { /// Crée un Device model UPnP complet pour un WebRenderer. /// - /// Le device est construit avec des action handlers qui relaient - /// les commandes SOAP vers le navigateur via le `ws_sender`. - pub fn create_device( - browser_name: &str, + /// `device_name` sert de clé pour retrouver l'UDN persistant dans la config. + /// `browser_ua` est le User-Agent complet (pour déterminer le nom affiché). + /// + /// Retourne le Device et le `SharedSender` associé. Le `SharedSender` peut être + /// mis à jour à chaque reconnexion WebSocket via `shared_sender.set(new_tx)`. + pub fn create_device_with_name( + device_name: &str, + browser_ua: &str, ws_sender: mpsc::UnboundedSender, state: SharedState, - ) -> Result { - let avtransport = Self::build_avtransport(ws_sender.clone(), state.clone())?; - let renderingcontrol = Self::build_renderingcontrol(ws_sender.clone(), state.clone())?; + ) -> Result<(Device, SharedSender), FactoryError> { + let shared_sender = SharedSender::new(ws_sender); + let avtransport = Self::build_avtransport(shared_sender.clone(), state.clone())?; + let renderingcontrol = Self::build_renderingcontrol(shared_sender.clone(), state.clone())?; let connectionmanager = Self::build_connectionmanager()?; - let short_name = extract_browser_name(browser_name); + let short_name = extract_browser_name(browser_ua); let device = Device::new( - "WebRenderer".to_string(), + device_name.to_string(), "MediaRenderer".to_string(), format!("Web Audio – {}", short_name), ); @@ -98,12 +103,12 @@ impl WebRendererFactory { .add_service(Arc::new(connectionmanager)) .map_err(|e| FactoryError::ServiceError(format!("{:?}", e)))?; - Ok(device) + Ok((device, shared_sender)) } /// Construit le service AVTransport avec les handlers WebSocket fn build_avtransport( - ws: mpsc::UnboundedSender, + ws: SharedSender, state: SharedState, ) -> Result { let mut svc = Service::new("AVTransport".to_string()); @@ -419,7 +424,7 @@ impl WebRendererFactory { /// Construit le service RenderingControl avec les handlers WebSocket fn build_renderingcontrol( - ws: mpsc::UnboundedSender, + ws: SharedSender, state: SharedState, ) -> Result { let mut svc = Service::new("RenderingControl".to_string()); diff --git a/pmowebrenderer/src/session.rs b/pmowebrenderer/src/session.rs index 07906736..5dae2d21 100644 --- a/pmowebrenderer/src/session.rs +++ b/pmowebrenderer/src/session.rs @@ -4,19 +4,18 @@ use parking_lot::RwLock; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, SystemTime}; -use tokio::sync::mpsc; use pmoupnp::devices::DeviceInstance; -use crate::messages::ServerMessage; -use crate::state::SharedState; +use crate::state::{SharedSender, SharedState}; /// Session WebSocket liée à un MediaRenderer privé pub struct WebRendererSession { pub token: String, pub udn: String, pub device_instance: Arc, - pub ws_sender: mpsc::UnboundedSender, + /// Sender partagé : mis à jour à chaque reconnexion WebSocket. + pub shared_sender: SharedSender, pub state: SharedState, pub created_at: SystemTime, pub last_activity: Arc>, @@ -26,6 +25,12 @@ pub struct WebRendererSession { #[derive(Clone)] pub struct SessionManager { sessions: Arc>>>, + /// Map UDN → SharedSender, persiste même après suppression de la session. + /// Permet de retrouver et mettre à jour le sender à la reconnexion. + senders: Arc>>, + /// Map UDN → SharedState, persiste même après suppression de la session. + /// Permet de réutiliser l'état partagé avec les handlers du device existant. + states: Arc>>, timeout_duration: Duration, } @@ -33,6 +38,8 @@ impl SessionManager { pub fn new(timeout_duration: Duration) -> Self { let manager = Self { sessions: Arc::new(RwLock::new(HashMap::new())), + senders: Arc::new(RwLock::new(HashMap::new())), + states: Arc::new(RwLock::new(HashMap::new())), timeout_duration, }; @@ -42,7 +49,12 @@ impl SessionManager { pub fn add_session(&self, session: Arc) { let token = session.token.clone(); + let udn = session.udn.clone(); + let sender = session.shared_sender.clone(); + let state = session.state.clone(); self.sessions.write().insert(token.clone(), session); + self.senders.write().insert(udn.clone(), sender); + self.states.write().insert(udn, state); tracing::info!(token = %token, "WebRenderer session added"); } @@ -56,6 +68,22 @@ impl SessionManager { } } + /// Retrouve une session par UDN du device (indépendant du token WebSocket). + pub fn get_session_by_udn(&self, udn: &str) -> Option> { + let sessions = self.sessions.read(); + sessions.values().find(|s| s.udn == udn).cloned() + } + + /// Retrouve le SharedSender par UDN (persiste même après suppression de session). + pub fn get_sender_by_udn(&self, udn: &str) -> Option { + self.senders.read().get(udn).cloned() + } + + /// Retrouve le SharedState par UDN (persiste même après suppression de session). + pub fn get_state_by_udn(&self, udn: &str) -> Option { + self.states.read().get(udn).cloned() + } + pub fn remove_session(&self, token: &str) -> Option> { let session = self.sessions.write().remove(token); if let Some(ref s) = session { diff --git a/pmowebrenderer/src/state.rs b/pmowebrenderer/src/state.rs index d419dfc5..9fa5c3f9 100644 --- a/pmowebrenderer/src/state.rs +++ b/pmowebrenderer/src/state.rs @@ -2,8 +2,9 @@ use parking_lot::RwLock; use std::sync::Arc; +use tokio::sync::mpsc; -use crate::messages::PlaybackState; +use crate::messages::{PlaybackState, ServerMessage}; /// État temps-réel du renderer (partagé backend ↔ navigateur) #[derive(Debug, Clone)] @@ -37,3 +38,34 @@ impl Default for RendererState { /// Alias pour l'état partagé pub type SharedState = Arc>; + +/// Sender WebSocket partagé et remplaçable entre les reconnexions. +/// +/// Les handlers UPnP capturent ce `Arc` à la création du device. À chaque +/// reconnexion WebSocket (reload de page), on remplace le sender interne via +/// `set()`, sans avoir à recréer le device ni ses handlers. +#[derive(Clone)] +pub struct SharedSender(Arc>>>); + +impl SharedSender { + pub fn new(sender: mpsc::UnboundedSender) -> Self { + Self(Arc::new(RwLock::new(Some(sender)))) + } + + /// Envoie un message au navigateur. Ignore silencieusement si déconnecté. + pub fn send(&self, msg: ServerMessage) { + if let Some(tx) = self.0.read().as_ref() { + let _ = tx.send(msg); + } + } + + /// Remplace le sender (appelé à la reconnexion WebSocket). + pub fn set(&self, sender: mpsc::UnboundedSender) { + *self.0.write() = Some(sender); + } + + /// Retire le sender (appelé à la déconnexion). + pub fn clear(&self) { + *self.0.write() = None; + } +} diff --git a/pmowebrenderer/src/websocket.rs b/pmowebrenderer/src/websocket.rs index e0b4326d..25a0d5c5 100644 --- a/pmowebrenderer/src/websocket.rs +++ b/pmowebrenderer/src/websocket.rs @@ -64,6 +64,7 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { }); let mut session_token: Option = None; + #[allow(unused_variables, unused_assignments, unused_mut)] let mut device_udn: Option = None; // Boucle de réception des messages du navigateur @@ -74,7 +75,7 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { match serde_json::from_str::(&text) { Ok(ClientMessage::Init { capabilities }) => { tracing::info!("WebRenderer Init received, creating renderer..."); - // Créer le renderer UPnP pour ce navigateur + // Créer ou reconnecter le renderer UPnP pour ce navigateur. match create_renderer_for_browser(&capabilities, tx.clone(), &state).await { Ok(session) => { tracing::info!("WebRenderer create_renderer_for_browser OK"); @@ -98,8 +99,32 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { }, }); + // Si une URI est déjà chargée (reconnexion en cours de lecture), + // envoyer l'état complet pour que le navigateur puisse reprendre. + { + let s = session.state.read(); + if s.current_uri.is_some() { + let _ = tx.send(ServerMessage::StateSync { + current_uri: s.current_uri.clone(), + current_metadata: s.current_metadata.clone(), + next_uri: s.next_uri.clone(), + next_metadata: s.next_metadata.clone(), + playback_state: s.playback_state.clone(), + position: s.position.clone(), + volume: s.volume, + mute: s.mute, + }); + tracing::info!( + udn = %udn, + state = ?s.playback_state, + "WebRenderer: sent StateSync to reconnected browser" + ); + } + } + session_token = Some(token.clone()); - device_udn = Some(udn.clone()); + #[cfg(feature = "pmoserver")] + { device_udn = Some(udn.clone()); } state.session_manager.add_session(session); @@ -237,7 +262,13 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { } } } - Ok(Message::Close(_)) => break, + Ok(Message::Binary(b)) => { + tracing::warn!("WebRenderer received binary message ({} bytes)", b.len()); + } + Ok(Message::Close(_)) => { + tracing::info!("WebRenderer WebSocket closed by client"); + break; + } Err(e) => { tracing::error!("WebSocket error: {}", e); break; @@ -247,6 +278,7 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { } // Cleanup à la déconnexion + tracing::info!("WebRenderer WebSocket handler exiting (session_token={:?})", session_token); if let Some(token) = session_token { state.session_manager.remove_session(&token); } @@ -263,65 +295,145 @@ async fn handle_socket(socket: WebSocket, state: WebSocketState) { send_task.abort(); } -/// Crée un DeviceInstance UPnP et l'enregistre pour un navigateur +/// Crée ou reconnecte un DeviceInstance UPnP pour un navigateur. +/// +/// - Première connexion : crée le device, l'enregistre, crée la session. +/// - Reconnexion (reload) : retrouve la session existante par UDN, met à jour le +/// `SharedSender` avec le nouveau tx WebSocket (les handlers continuent de fonctionner), +/// et crée une nouvelle session avec un nouveau token. async fn create_renderer_for_browser( capabilities: &BrowserCapabilities, ws_sender: mpsc::UnboundedSender, ws_state: &WebSocketState, ) -> Result, crate::error::WebRendererError> { - let shared_state: SharedState = Arc::new(RwLock::new(RendererState::default())); let token = Uuid::new_v4().to_string(); - // Construire le Device model avec les handlers WS - tracing::info!("WebRenderer: creating device model..."); - let device = WebRendererFactory::create_device( - &capabilities.user_agent, - ws_sender.clone(), - shared_state.clone(), - ) - .map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?; - tracing::info!("WebRenderer: device model created"); + // Persister l'UDN dérivé de l'instance_id dans la config pour que device_instance.rs + // le retrouve de façon déterministe. La clé ("MediaRenderer", instance_id) est unique + // par onglet/navigateur et stable entre les reloads. + let instance_udn = capabilities.instance_id.clone(); + if let Err(e) = pmoconfig::get_config().set_device_udn( + "MediaRenderer", + &instance_udn, + instance_udn.clone(), + ) { + tracing::warn!("WebRenderer: failed to persist UDN in config: {:?}", e); + } - let device = Arc::new(device); + // UDN normalisé tel que stocké dans le DEVICE_REGISTRY (sans préfixe "uuid:") + let candidate_udn = instance_udn.to_ascii_lowercase(); + // UDN avec préfixe "uuid:" pour le ControlPoint et la session + let full_udn = format!("uuid:{}", candidate_udn); + + // ── Reconnexion : session existante par UDN ─────────────────────────────── + // Si une session avec ce même UDN existe encore dans le SessionManager, on + // met à jour son SharedSender (les handlers UPnP enverront vers le nouveau WS). + if let Some(existing_session) = ws_state.session_manager.get_session_by_udn(&full_udn) { + tracing::info!(udn = %full_udn, "WebRenderer: reconnecting via existing session"); + existing_session.shared_sender.set(ws_sender.clone()); + + #[cfg(feature = "pmoserver")] + register_with_control_point(&existing_session.device_instance, ws_state)?; + + // Nouvelle session avec nouveau token, mais même device/state/sender partagés + let session = Arc::new(WebRendererSession { + token, + udn: full_udn, + device_instance: existing_session.device_instance.clone(), + shared_sender: existing_session.shared_sender.clone(), + state: existing_session.state.clone(), + created_at: existing_session.created_at, + last_activity: existing_session.last_activity.clone(), + }); + return Ok(session); + } + + // ── Première connexion : création complète ──────────────────────────────── // Enregistrer le device via UpnpServerExt (gère base_url, register_urls, DEVICE_REGISTRY) + // Retourne (DeviceInstance, SharedSender effectif, SharedState effective pour cette session) #[cfg(feature = "pmoserver")] - let di = { + let (di, shared_sender, shared_state) = { use pmoupnp::UpnpServerExt; - tracing::info!("WebRenderer: getting server arc..."); + tracing::info!("WebRenderer: candidate UDN = {}", candidate_udn); + + // Vérifier si un device avec ce même UDN est déjà dans le DEVICE_REGISTRY + // (cas où la session a expiré mais le device est encore enregistré). let server_arc = pmoserver::get_server().ok_or(crate::error::WebRendererError::ServerNotAvailable)?; - tracing::info!("WebRenderer: got server arc, acquiring write lock..."); - - let di = { - let mut server = server_arc.write().await; - tracing::info!("WebRenderer: write lock acquired, registering device..."); - server - .register_device(device) - .await - .map_err(|e| crate::error::WebRendererError::RegistrationError(e.to_string()))? + let existing_di = { + let server = server_arc.read().await; + server.get_device(&candidate_udn) }; - tracing::info!("WebRenderer: device registered, registering with ControlPoint..."); - // Enregistrer avec le ControlPoint - register_with_control_point(&di, ws_state)?; - tracing::info!("WebRenderer: registered with ControlPoint"); + if let Some(di) = existing_di { + tracing::info!(udn = %candidate_udn, "WebRenderer: reusing device from registry (session expired)"); + // Mettre à jour le SharedSender de ce device (session supprimée mais device toujours dans registry). + // Le SharedSender et le SharedState sont ceux capturés dans les handlers du di existant. + let effective_sender = if let Some(existing_sender) = ws_state.session_manager.get_sender_by_udn(&full_udn) { + existing_sender.set(ws_sender); + tracing::info!(udn = %full_udn, "WebRenderer: updated SharedSender for reused device"); + existing_sender + } else { + // Fallback : ne devrait pas arriver mais on crée un sender neuf + tracing::warn!(udn = %full_udn, "WebRenderer: no SharedSender found for reused device"); + let new_state: SharedState = Arc::new(RwLock::new(RendererState::default())); + let (_, new_sender) = WebRendererFactory::create_device_with_name( + &instance_udn, &capabilities.user_agent, ws_sender, new_state.clone(), + ).map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?; + new_sender + }; + let effective_state = ws_state.session_manager.get_state_by_udn(&full_udn) + .unwrap_or_else(|| Arc::new(RwLock::new(RendererState::default()))); + register_with_control_point(&di, ws_state)?; + (di, effective_sender, effective_state) + } else { + // Véritablement première connexion : créer device + state + sender + let new_state: SharedState = Arc::new(RwLock::new(RendererState::default())); + tracing::info!("WebRenderer: creating device model..."); + let (device, new_sender) = WebRendererFactory::create_device_with_name( + &instance_udn, + &capabilities.user_agent, + ws_sender, + new_state.clone(), + ) + .map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?; + tracing::info!("WebRenderer: device model created"); - di + let device = Arc::new(device); + tracing::info!("WebRenderer: registering new device..."); + let di = { + let mut server = server_arc.write().await; + server + .register_device(device) + .await + .map_err(|e| crate::error::WebRendererError::RegistrationError(e.to_string()))? + }; + tracing::info!("WebRenderer: device registered"); + register_with_control_point(&di, ws_state)?; + (di, new_sender, new_state) + } }; #[cfg(not(feature = "pmoserver"))] - let di = device.create_instance(); - - // Normaliser l'UDN avec le préfixe "uuid:" pour correspondre au format SSDP - let udn = format!("uuid:{}", di.udn().to_ascii_lowercase()); + let (di, shared_sender, shared_state) = { + let new_state: SharedState = Arc::new(RwLock::new(RendererState::default())); + let (device, new_sender) = WebRendererFactory::create_device_with_name( + &instance_udn, + &capabilities.user_agent, + ws_sender, + new_state.clone(), + ) + .map_err(|e| crate::error::WebRendererError::DeviceCreationError(e.to_string()))?; + (Arc::new(device).create_instance(), new_sender, new_state) + }; let session = Arc::new(WebRendererSession { token, - udn, + udn: full_udn, device_instance: di, - ws_sender, + shared_sender, state: shared_state, created_at: SystemTime::now(), last_activity: Arc::new(RwLock::new(SystemTime::now())), diff --git a/version.txt b/version.txt index f9a4b5f9..dfdc3688 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.20 +0.3.21