From 56e1f4c0fbb2cd9458a27d4a6587038edc46f541 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 10 Jan 2026 14:58:45 +0100 Subject: [PATCH 1/3] Unified playback behavior and playlist binding detachment This commit ensures unified playback behavior across all backends by automatically playing the current track from the queue when available. It also detaches playlist bindings from source renderers during control point operations, improving consistency and reliability of music playback transitions. --- pmocontrol/src/control_point.rs | 9 +++++++++ pmocontrol/src/music_renderer/musicrenderer.rs | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) 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..71672f66 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 From c745179200289c4beed033b7b7a9148cbecaaf8e Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 10 Jan 2026 15:08:44 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Impl=C3=A9mentation=20du=20seek=20audio=20a?= =?UTF-8?q?vec=20support=20des=20Range=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de la fonctionnalité de seek audio dans les renderers avec support des Range requests HTTP pour permettre le seek pendant le téléchargement. - Ajout de la route /control/renderers/{renderer_id}/seek pour le seek par secondes - Implémentation du support des Range requests dans le serveur de fichiers (pmocache) - Mise à jour des dépendances avec http-range-header, tower, tower-http - Amélioration de l'interface utilisateur avec une barre de progression interactive dans CurrentTrack.vue - Support du seek pendant le téléchargement avec streaming progressif - Gestion des erreurs et timeouts pour les commandes de seek --- Cargo.lock | 18 + .../components/pmocontrol/CurrentTrack.vue | 928 +++++++++++------- .../pmocontrol/TransportControls.vue | 290 +++--- pmoapp/webapp/src/services/pmocontrol/api.ts | 14 + pmocache/Cargo.toml | 4 +- pmocache/src/pmoserver_ext.rs | 135 ++- .../src/music_renderer/musicrenderer.rs | 10 + pmocontrol/src/openapi.rs | 10 + pmocontrol/src/pmoserver_ext.rs | 85 +- 9 files changed, 982 insertions(+), 512 deletions(-) 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..3a30ca90 100644 --- a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue +++ b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue @@ -1,532 +1,772 @@ 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/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/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 71672f66..e47d2f71 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -352,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 Date: Sat, 10 Jan 2026 16:39:29 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Ajout=20d'un=20padding=20=C3=A0=20la=20list?= =?UTF-8?q?e=20des=20pistes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'un padding de 10px à gauche et à droite dans la liste des pistes pour améliorer l'espacement et l'alignement visuel. --- .../components/pmocontrol/CurrentTrack.vue | 1 + pmoapp/webapp/src/style.css | 106 +++++++++--------- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue index 3a30ca90..548bd5a0 100644 --- a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue +++ b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue @@ -356,6 +356,7 @@ async function handleProgressBarClick(event: MouseEvent) { display: flex; flex-direction: column; gap: var(--spacing-xs); + padding: 0 10px; } .progress-bar { 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; + } }