diff --git a/Cargo.lock b/Cargo.lock index 2fb78f6f..e7b98fb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2084,6 +2084,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3677,6 +3683,8 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "tower 0.5.2", + "tower-http", "tracing", "utoipa", ] @@ -5844,14 +5852,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue index f1b71e49..548bd5a0 100644 --- a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue +++ b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue @@ -1,532 +1,773 @@ - - - - - - - - - - - - {{ metadata?.title || 'Aucun titre' }} - {{ metadata?.artist || 'Artiste inconnu' }} - {{ metadata.album }} - - - - - + + - - - {{ currentTime }} - {{ totalTime }} - - - - - - - - - - - + class="cover-container" + :class="{ clickable: hasCover }" + @click="openCoverOverlay" + > - - - {{ metadata.title }} - {{ metadata.artist }} - {{ metadata.album }} - - - + + + - - - + + + + {{ metadata?.title || "Aucun titre" }} + {{ metadata?.artist || "Artiste inconnu" }} + {{ metadata.album }} + + + + + + + + + + + {{ currentTime }} + {{ totalTime }} + + + + + + + + + + + + + + + {{ metadata.title }} + {{ metadata.artist }} + + {{ metadata.album }} + + + + + + + + diff --git a/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue index caf673b3..aac9d5fb 100644 --- a/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue +++ b/pmoapp/webapp/src/components/pmocontrol/TransportControls.vue @@ -1,209 +1,221 @@ - - - - + + + + - - - + + + - - - + + + - - - - + + + + diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index 1907008b..1819aa22 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -168,6 +168,20 @@ class PMOControlAPI { ); } + /** + * Seek à une position spécifique (en secondes) + * POST /api/control/renderers/{id}/seek + */ + async seekTo(id: string, seconds: number): Promise { + return this.request( + `/renderers/${encodeURIComponent(id)}/seek`, + { + method: "POST", + body: JSON.stringify({ seconds }), + }, + ); + } + /** * Saute à un index spécifique dans la queue * POST /api/control/renderers/{id}/queue/seek diff --git a/pmoapp/webapp/src/style.css b/pmoapp/webapp/src/style.css index be0def4e..8167e82c 100644 --- a/pmoapp/webapp/src/style.css +++ b/pmoapp/webapp/src/style.css @@ -1,84 +1,86 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } a:hover { - color: #535bf2; + color: #535bf2; } body { - margin: 0; - display: flex; - flex-direction: column; - min-width: 320px; - min-height: 100vh; - width: 100%; - overflow-x: hidden; + margin: 0; + display: flex; + flex-direction: column; + min-width: 320px; + min-height: 100vh; + width: 100%; + overflow-x: hidden; } h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 3.2em; + line-height: 1.1; } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; } button:hover { - border-color: #646cff; + border-color: #646cff; } button:focus, button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; + outline: 4px auto -webkit-focus-ring-color; } .card { - padding: 2em; + padding: 2em; } #app { - width: 100%; - min-height: 100vh; - margin: 0; - padding: 0; - box-sizing: border-box; - display: flex; - flex-direction: column; + width: 100%; + min-height: 100vh; + margin: 0; + padding: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + zoom: 0.85; + transform-origin: top left; } @media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } } diff --git a/pmocache/Cargo.toml b/pmocache/Cargo.toml index c188ee87..1b4dd4b7 100644 --- a/pmocache/Cargo.toml +++ b/pmocache/Cargo.toml @@ -38,6 +38,8 @@ utoipa = { version = "5.3", optional = true } # Feature pour pmoserver (extension HTTP) axum = { version = "0.8", optional = true } +tower = { version = "0.5", optional = true } +tower-http = { version = "0.6", features = ["fs"], optional = true } # Feature pour pmoconfig (extension de configuration) pmoconfig = { path = "../pmoconfig", optional = true } @@ -49,5 +51,5 @@ tempfile = "3" [features] default = [] openapi = ["dep:utoipa"] -pmoserver = ["dep:axum"] +pmoserver = ["dep:axum", "dep:tower", "dep:tower-http"] pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"] diff --git a/pmocache/src/pmoserver_ext.rs b/pmocache/src/pmoserver_ext.rs index a9b03bdc..e046c8d1 100644 --- a/pmocache/src/pmoserver_ext.rs +++ b/pmocache/src/pmoserver_ext.rs @@ -90,10 +90,11 @@ async fn get_file( Option>, )>, Path(pk): Path, + request: axum::http::Request, ) -> Response { // Utiliser le param par défaut let param = C::default_param(); - serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await + serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await } /// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param} @@ -106,8 +107,9 @@ async fn get_file_with_param( Option>, )>, Path((pk, param)): Path<(String, String)>, + request: axum::http::Request, ) -> Response { - serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator).await + serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator, request).await } #[cfg(feature = "pmoserver")] @@ -116,6 +118,7 @@ async fn serve_finalized_pk( pk: &str, param: &str, content_type: &'static str, + request: axum::http::Request, ) -> Response { let qualifier = param.to_string(); let file_path = cache.get_file_path_with_qualifier(pk, param); @@ -129,7 +132,8 @@ async fn serve_finalized_pk( // la lecture pendant le téléchargement tout en préservant la durée/position if let Some(download) = cache.get_download(pk).await { if !download.finished().await { - let response = stream_file_progressive(file_path, download, content_type).await; + let response = + stream_file_progressive(file_path, download, content_type, request).await; if response.status().is_success() { cache.notify_broadcast(pk, &qualifier).await; @@ -140,9 +144,8 @@ async fn serve_finalized_pk( } // ROUTE 2 : Fichier complètement téléchargé - // Utilise l'ancien système éprouvé qui garantit un passage correct - // de toutes les informations (Content-Length automatique, etc.) - let response = serve_complete_file(file_path, content_type).await; + // Utilise ServeFile avec support des Range requests pour le seek + let response = serve_complete_file(file_path, content_type, request).await; if response.status().is_success() { cache.notify_broadcast(pk, &qualifier).await; @@ -166,6 +169,7 @@ async fn serve_lazy_audio_file( lazy_pk: &str, param: &str, content_type: &'static str, + request: axum::http::Request, ) -> Response { tracing::info!("Lazy download triggered for pk: {}", lazy_pk); @@ -176,7 +180,7 @@ async fn serve_lazy_audio_file( lazy_pk, real_pk ); - return serve_finalized_pk(cache, &real_pk, param, content_type).await; + return serve_finalized_pk(cache, &real_pk, param, content_type, request).await; } // 2. Télécharger en résolvant l'URL via la DB ou un provider @@ -196,7 +200,7 @@ async fn serve_lazy_audio_file( cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await; // 5. Servir directement le fichier téléchargé - serve_finalized_pk(cache, &real_pk, param, content_type).await + serve_finalized_pk(cache, &real_pk, param, content_type, request).await } /// Fonction utilitaire pour servir un fichier avec streaming progressif @@ -211,10 +215,11 @@ async fn serve_file_with_streaming( param: &str, content_type: &'static str, param_generator: Option>, + request: axum::http::Request, ) -> Response { // LAZY PK SUPPORT: Détecter si c'est un lazy PK if crate::cache::is_lazy_pk(pk) { - return serve_lazy_audio_file(cache, pk, param, content_type).await; + return serve_lazy_audio_file(cache, pk, param, content_type, request).await; } let file_path = cache.get_file_path_with_qualifier(pk, param); @@ -246,8 +251,9 @@ async fn serve_file_with_streaming( if let Some(download) = cache.get_download(pk).await { // Le fichier est en cours de téléchargement if !download.finished().await { - // Streaming progressif - let response = stream_file_progressive(file_path, download, content_type).await; + // Streaming progressif avec support Range + let response = + stream_file_progressive(file_path, download, content_type, request).await; if response.status().is_success() { cache.notify_broadcast(pk, &qualifier).await; @@ -257,8 +263,8 @@ async fn serve_file_with_streaming( } } - // Fichier terminé ou pas de download en cours, servir normalement - let response = serve_complete_file(file_path, content_type).await; + // Fichier terminé ou pas de download en cours, servir normalement avec support Range + let response = serve_complete_file(file_path, content_type, request).await; if response.status().is_success() { cache.notify_broadcast(pk, &qualifier).await; @@ -268,13 +274,17 @@ async fn serve_file_with_streaming( } /// Stream un fichier en cours de téléchargement de manière progressive +/// +/// Supporte les Range requests pour permettre le seek même pendant le téléchargement #[cfg(feature = "pmoserver")] async fn stream_file_progressive( file_path: std::path::PathBuf, download: Arc, content_type: &'static str, + request: axum::http::Request, ) -> Response { use axum::http::header; + use tokio::io::{AsyncReadExt, AsyncSeekExt}; // Attendre qu'au moins 64 KB soient disponibles avant de commencer const MIN_SIZE_TO_START: u64 = 64 * 1024; @@ -291,8 +301,14 @@ async fn stream_file_progressive( return (StatusCode::NOT_FOUND, "File not available").into_response(); } + // Récupérer la taille attendue du fichier si disponible + let expected_size = download.expected_size().await; + + // Vérifier s'il y a un header Range + let range_header = request.headers().get(header::RANGE); + // Ouvrir le fichier en lecture - let file = match tokio::fs::File::open(&file_path).await { + let mut file = match tokio::fs::File::open(&file_path).await { Ok(f) => f, Err(e) => { warn!("Error opening file {:?}: {}", file_path, e); @@ -300,24 +316,65 @@ async fn stream_file_progressive( } }; - // Créer un stream à partir du fichier + // Parser le Range header si présent + if let Some(range_value) = range_header { + if let Ok(range_str) = range_value.to_str() { + // Format: "bytes=start-end" ou "bytes=start-" + if let Some(range_spec) = range_str.strip_prefix("bytes=") { + if let Some((start_str, _)) = range_spec.split_once('-') { + if let Ok(start) = start_str.parse::() { + // Seek vers la position demandée + if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await { + warn!("Error seeking to position {}: {}", start, e); + return (StatusCode::RANGE_NOT_SATISFIABLE, "Invalid range") + .into_response(); + } + + // Créer un stream à partir de la position + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + // Calculer la range + let end = expected_size.map(|s| s - 1).unwrap_or(start + 1_000_000); + let content_length = expected_size.map(|s| s - start); + + // Retourner une réponse 206 Partial Content + let mut response_builder = axum::http::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header(header::ACCEPT_RANGES, "bytes"); + + if let Some(total_size) = expected_size { + response_builder = response_builder.header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, end, total_size), + ); + } + + if let Some(length) = content_length { + response_builder = + response_builder.header(header::CONTENT_LENGTH, length); + } + + return response_builder.body(body).unwrap(); + } + } + } + } + } + + // Pas de Range header : retourner le fichier complet let stream = ReaderStream::new(file); let body = Body::from_stream(stream); - // Récupérer la taille attendue du fichier si disponible - let expected_size = download.expected_size().await; - - // Construire la réponse avec Content-Length si connu let mut response = axum::http::Response::builder() .status(StatusCode::OK) - .header(header::CONTENT_TYPE, content_type); + .header(header::CONTENT_TYPE, content_type) + .header(header::ACCEPT_RANGES, "bytes"); if let Some(size) = expected_size { - // Si on connaît la taille finale, l'envoyer au renderer - // pour qu'il puisse calculer la durée et afficher la position response = response.header(header::CONTENT_LENGTH, size); } else { - // Sinon, utiliser chunked encoding response = response.header(header::TRANSFER_ENCODING, "chunked"); } @@ -325,21 +382,45 @@ async fn stream_file_progressive( } /// Sert un fichier complet déjà téléchargé +/// +/// Utilise `tower_http::services::ServeFile` pour bénéficier automatiquement de : +/// - Support des HTTP Range requests (seek) +/// - Header `Accept-Ranges: bytes` +/// - Réponses `206 Partial Content` +/// - Streaming efficace sans chargement complet en RAM #[cfg(feature = "pmoserver")] async fn serve_complete_file( file_path: std::path::PathBuf, content_type: &'static str, + request: axum::http::Request, ) -> Response { + use axum::http::header; + use tower::ServiceExt; // Pour oneshot() + use tower_http::services::ServeFile; + if !file_path.exists() { warn!("File not found: {:?}", file_path); return (StatusCode::NOT_FOUND, "File not found").into_response(); } - match tokio::fs::read(&file_path).await { - Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(), + // Utiliser ServeFile avec support automatique des Range requests + // ServeFile traite automatiquement les headers Range: dans la requête + let serve_file = ServeFile::new(file_path); + + match serve_file.oneshot(request).await { + Ok(response) => { + // Convertir la réponse ServeFile en Response avec le bon Content-Type + let (mut parts, body) = response.into_parts(); + parts + .headers + .insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + + // Convertir ServeFileSystemResponseBody en Body + axum::http::Response::from_parts(parts, Body::new(body)) + } Err(e) => { - warn!("Error reading file {:?}: {}", file_path, e); - (StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response() + warn!("Error serving file: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response() } } } diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 08f44e8a..07b67053 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -1318,8 +1318,17 @@ impl ControlPoint { ); } + // 5b. Detach playlist binding from source renderer + self.detach_queue_playlist(source_renderer_id); + tracing::debug!( + source = source_renderer_id.0.as_str(), + "Detached playlist binding from source renderer" + ); + // 6. Start playback on destination renderer (if there was a current item) if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() { + // play() détecte automatiquement la queue et joue le track courant + // (comportement unifié pour tous les backends) if let Err(e) = dest_renderer.play() { tracing::warn!( dest = dest_renderer_id.0.as_str(), diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 682a6d30..e47d2f71 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -298,8 +298,24 @@ impl MusicRenderer { } /// Transport control: play + /// + /// Démarre ou reprend la lecture. Si une queue non vide existe, + /// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends). pub fn play(&self) -> Result<(), ControlPointError> { - self.backend.lock().expect("Backend mutex poisoned").play() + // Vérifier si on a une queue non vide + let queue = self.queue.lock().expect("Queue mutex poisoned"); + let queue_not_empty = queue.len().unwrap_or(0) > 0; + drop(queue); // Libérer le lock avant l'appel au backend + + 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é) + // peek_current() initialise automatiquement l'index à 0 si nécessaire + // Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.) + self.play_current_from_queue() + } else { + // Queue vide : déléguer au backend (reprend la lecture en cours, etc.) + self.backend.lock().expect("Backend mutex poisoned").play() + } } /// Transport control: pause @@ -336,6 +352,16 @@ impl MusicRenderer { .seek_rel_time(hhmmss) } + /// Seek to a specific position in seconds + pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> { + // Convert seconds to HH:MM:SS format + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs); + self.seek_rel_time(&hhmmss) + } + /// Volume control: get current volume pub fn volume(&self) -> Result { self.backend diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 9af5feb1..67cf9d6a 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -266,6 +266,14 @@ pub struct SeekQueueRequest { pub index: usize, } +/// Requête pour seek à une position spécifique (en secondes) +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct SeekRequest { + /// Position en secondes + pub seconds: u32, +} + /// Requête pour transférer une queue d'un renderer vers un autre #[cfg(feature = "pmoserver")] #[derive(Debug, Clone, Deserialize, ToSchema)] @@ -412,6 +420,7 @@ GET /control/servers/{server_id}/containers/{container_id} crate::pmoserver_ext::stop_renderer, crate::pmoserver_ext::resume_renderer, crate::pmoserver_ext::next_renderer, + crate::pmoserver_ext::seek_renderer, crate::pmoserver_ext::seek_queue_index, crate::pmoserver_ext::set_renderer_volume, crate::pmoserver_ext::volume_up_renderer, @@ -445,6 +454,7 @@ GET /control/servers/{server_id}/containers/{container_id} AttachPlaylistRequest, PlayContentRequest, SeekQueueRequest, + SeekRequest, TransferQueueRequest, SuccessResponse, ErrorResponse, diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index b49bcb71..dd24522a 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -14,7 +14,7 @@ use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, - SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest, + SeekQueueRequest, SeekRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] use crate::queue::PlaybackItem; @@ -718,6 +718,88 @@ async fn seek_queue_index( })) } +/// POST /control/renderers/{renderer_id}/seek - Seek à une position spécifique (en secondes) +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/control/renderers/{renderer_id}/seek", + tag = "control", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = SeekRequest, + responses( + (status = 200, description = "Seek effectué avec succès", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 504, description = "Timeout lors du seek", body = ErrorResponse), + (status = 500, description = "Erreur interne", body = ErrorResponse), + ) +)] +async fn seek_renderer( + State(state): State, + Path(renderer_id): Path, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + 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 seconds = payload.seconds; + let seek_task = tokio::task::spawn_blocking(move || renderer.seek(seconds)); + + time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task) + .await + .map_err(|_| { + warn!( + "Seek command for renderer {} exceeded {:?}", + renderer_id, TRANSPORT_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Seek command timed out after {}s", + TRANSPORT_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during seek: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to seek to {} seconds for renderer {}: {}", + seconds, renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to seek to {} seconds: {}", seconds, e), + }), + ) + })?; + + Ok(Json(SuccessResponse { + message: format!("Seeked to {} seconds", seconds), + })) +} + /// POST /control/renderers/{renderer_id}/volume/set - Définit le volume #[cfg(feature = "pmoserver")] #[utoipa::path( @@ -1882,6 +1964,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc
{{ metadata?.artist || 'Artiste inconnu' }}
{{ metadata.album }}
{{ metadata.artist }}
{{ metadata?.artist || "Artiste inconnu" }}
+ {{ metadata.album }} +