From ead7cab237d0701614dcafaf85ff5b7e7e7527d7 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 15:03:56 +0800 Subject: [PATCH 1/9] feat(media): use Core-owned browsing fast paths --- rust/frontend/src/media_image_cache.rs | 252 ++++++++++++++++-- .../frontend/src/models/alternate_versions.rs | 1 + rust/frontend/src/models/favorites.rs | 27 +- rust/frontend/src/models/recents.rs | 84 +++--- rust/mock-core/src/fixtures.rs | 3 + rust/mock-core/src/handler.rs | 4 +- rust/zaparoo-core/src/client.rs | 6 +- .../src/endpoints/media_history.rs | 1 + rust/zaparoo-core/src/media_types.rs | 131 ++++++++- 9 files changed, 429 insertions(+), 80 deletions(-) diff --git a/rust/frontend/src/media_image_cache.rs b/rust/frontend/src/media_image_cache.rs index 3305bfbc..6815aecd 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -34,7 +34,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::{c_char, c_void}; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use std::time::{Duration, Instant}; @@ -44,7 +44,12 @@ use tokio::runtime::Handle; use tokio::sync::{broadcast, Notify}; use tracing::{debug, info, warn}; -use zaparoo_core::media_types::{MediaImageParams, MediaImageResult}; +use zaparoo_core::client::ClientError; +use zaparoo_core::media_types::{ + MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_INLINE, + MEDIA_IMAGE_DELIVERY_LOCAL_PATH, +}; +use zaparoo_core::runtime; use zaparoo_core::store::Store; /// Field separator used inside the encoded key. Unit Separator (US, @@ -86,6 +91,11 @@ const MAX_FETCH_ATTEMPTS: u8 = 3; /// this before trying any broader queue changes. const FETCH_DRIVER_WORKERS: usize = 2; +/// Set after a connected older Core explicitly rejects the additive delivery +/// parameter. The frontend then stays inline for the process lifetime instead +/// of doubling every cover request with a known-unsupported probe. +static LOCAL_PATH_REQUESTS_DISABLED: AtomicBool = AtomicBool::new(false); + /// Hard cap on pending enqueues in the fetch queue. Sized for a few /// dense visual pages (current, lookahead, previous) plus margin, so /// an explicit page-window rebuild does not drop the previous-page @@ -1320,9 +1330,121 @@ fn fetch_outcome_label(outcome: &FetchOutcome) -> &'static str { } } -/// Fetch one media image with one `media.image` JSON-RPC call. Core no -/// longer accepts batched `items`, so queue fan-out happens entirely in -/// this single-flight driver. +struct FetchedMediaImage { + image: MediaImageResult, + local_bytes: Option>, + rpc_duration: Duration, + path_read_duration: Duration, +} + +fn should_request_local_path(max_size: u32) -> bool { + max_size > 0 + && runtime::current().is_mister() + && !LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Relaxed) +} + +fn is_unsupported_local_path_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("delivery") + && (message.contains("unknown") + || message.contains("unsupported") + || message.contains("invalid params")) +} + +async fn read_local_image(path: String) -> Result, String> { + match tokio::task::spawn_blocking(move || std::fs::read(path)).await { + Ok(Ok(bytes)) if !bytes.is_empty() => Ok(bytes), + Ok(Ok(_)) => Err("thumbnail file was empty".to_string()), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(format!("blocking thumbnail read failed: {error}")), + } +} + +async fn fetch_media_image_payload( + store: &Arc, + key: &MediaKey, + mut params: MediaImageParams, + request_local_path: bool, +) -> Result { + if request_local_path { + params.delivery = Some(MEDIA_IMAGE_DELIVERY_LOCAL_PATH.to_string()); + } + + let rpc_started = Instant::now(); + let first = store.client().media_image(params.clone()).await; + let mut rpc_duration = rpc_started.elapsed(); + let mut path_read_duration = Duration::ZERO; + + let image = match first { + Ok(image) => image, + Err(error) if request_local_path && is_unsupported_local_path_error(&error.message) => { + LOCAL_PATH_REQUESTS_DISABLED.store(true, Ordering::Relaxed); + warn!( + system_id = %key.system_id, + path = %key.path, + "media_image_cache: Core rejected local-path delivery; using inline for this session" + ); + params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); + let fallback_started = Instant::now(); + let fallback = store.client().media_image(params.clone()).await; + rpc_duration += fallback_started.elapsed(); + fallback? + } + Err(error) => return Err(error), + }; + + if image.delivery != MEDIA_IMAGE_DELIVERY_LOCAL_PATH { + return Ok(FetchedMediaImage { + image, + local_bytes: None, + rpc_duration, + path_read_duration, + }); + } + + let path = image.local_path.clone().filter(|path| !path.is_empty()); + if let Some(path) = path { + let read_started = Instant::now(); + let read_result = read_local_image(path.clone()).await; + path_read_duration = read_started.elapsed(); + match read_result { + Ok(bytes) => { + return Ok(FetchedMediaImage { + image, + local_bytes: Some(bytes), + rpc_duration, + path_read_duration, + }); + } + Err(error) => warn!( + system_id = %key.system_id, + media_path = %key.path, + local_path = %path, + "media_image_cache: local thumbnail read failed, retrying inline: {error}" + ), + } + } else { + warn!( + system_id = %key.system_id, + path = %key.path, + "media_image_cache: local-path response omitted localPath, retrying inline" + ); + } + + params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); + let fallback_started = Instant::now(); + let fallback = store.client().media_image(params).await; + rpc_duration += fallback_started.elapsed(); + Ok(FetchedMediaImage { + image: fallback?, + local_bytes: None, + rpc_duration, + path_read_duration, + }) +} + +/// Fetch one media image. Queue fan-out happens entirely in this driver; +/// local-path read failures may issue one documented inline fallback request. async fn fetch_one( store: &Arc, state: &Arc>, @@ -1357,6 +1479,7 @@ async fn fetch_one( if max_size > 0 { params.max_size = Some(max_size); } + let request_local_path = should_request_local_path(max_size); debug!( system_id = %key.system_id, path = %key.path, @@ -1365,20 +1488,35 @@ async fn fetch_one( policy = ?entry.no_image_policy, page_size = entry.page_size, max_size, + request_local_path, "media_image_cache: media.image request" ); let fetch_started = Instant::now(); - let result = store.client().media_image(params).await; + let result = fetch_media_image_payload(store, &key, params, request_local_path).await; let fetch_duration = fetch_started.elapsed(); - let (outcome, decode_duration) = match result { - Ok(image) => classify_media_image_result(&key, &image), + let (outcome, decode_duration, rpc_duration, path_read_duration) = match result { + Ok(payload) => { + let (outcome, decode_duration) = match payload.local_bytes { + Some(bytes) => ( + classify_media_image_bytes(&key, &payload.image, bytes), + Duration::ZERO, + ), + None => classify_media_image_result(&key, &payload.image), + }; + ( + outcome, + decode_duration, + payload.rpc_duration, + payload.path_read_duration, + ) + } Err(e) => { let outcome = classify_single_media_image_error(&key, &e.message, had_id_hint); if matches!(outcome, FetchOutcome::Transient) && had_id_hint { #[allow(clippy::unwrap_used, reason = "RwLock poisoning is unrecoverable")] state.write().unwrap().media_ids.remove(&key); } - (outcome, Duration::ZERO) + (outcome, Duration::ZERO, fetch_duration, Duration::ZERO) } }; debug!( @@ -1387,6 +1525,8 @@ async fn fetch_one( outcome = fetch_outcome_label(&outcome), queue_wait_ms = queue_wait.as_millis(), fetch_ms = fetch_duration.as_millis(), + rpc_ms = rpc_duration.as_millis(), + path_read_ms = path_read_duration.as_millis(), decode_ms = decode_duration.as_millis(), "media_image_cache: cover timing", ); @@ -1459,13 +1599,24 @@ fn classify_media_image_result( } }; let decode_duration = decode_started.elapsed(); + ( + classify_media_image_bytes(key, image, bytes), + decode_duration, + ) +} + +fn classify_media_image_bytes( + key: &MediaKey, + image: &MediaImageResult, + bytes: Vec, +) -> FetchOutcome { if bytes.is_empty() { warn!( system_id = %key.system_id, path = %key.path, - "media_image_cache: media.image returned 0 bytes after base64 decode, treating as no image", + "media_image_cache: media.image returned 0 bytes, treating as no image", ); - return (FetchOutcome::NoImage, decode_duration); + return FetchOutcome::NoImage; } let ext = image .extension @@ -1481,7 +1632,7 @@ fn classify_media_image_result( bytes_len = bytes.len(), "media_image_cache: unsupported extension/content_type, skipping cache", ); - return (FetchOutcome::NoImage, decode_duration); + return FetchOutcome::NoImage; }; // Strip the canonical prefix so the stored value is the bare type // name (e.g. "boxart"), matching MediaKey::image_type values used by @@ -1491,14 +1642,11 @@ fn classify_media_image_result( .strip_prefix("property:image-") .unwrap_or("") .to_string(); - ( - FetchOutcome::Success { - bytes, - ext, - type_tag, - }, - decode_duration, - ) + FetchOutcome::Success { + bytes, + ext, + type_tag, + } } fn finish_fetch( @@ -1706,16 +1854,20 @@ mod tests { )] use super::{ - classify_single_media_image_error, ext_for_content_type, ext_from_extension_field, - finish_fetch, is_connection_down_error, pop_one, process_batch_outcomes, CacheState, - FetchOutcome, MediaImageCache, MediaImageUpdate, MediaKey, NegativeMemo, NoImagePolicy, - QueueEntry, MAX_QUEUE_LEN, NEGATIVE_MEMO_CAP, + classify_media_image_bytes, classify_single_media_image_error, ext_for_content_type, + ext_from_extension_field, finish_fetch, is_connection_down_error, + is_unsupported_local_path_error, pop_one, process_batch_outcomes, read_local_image, + CacheState, FetchOutcome, MediaImageCache, MediaImageUpdate, MediaKey, NegativeMemo, + NoImagePolicy, QueueEntry, MAX_QUEUE_LEN, NEGATIVE_MEMO_CAP, }; use std::collections::VecDeque; + use std::io::Write as _; use std::sync::atomic::AtomicU32; use std::sync::{Arc, Mutex, RwLock}; use std::time::Instant; + use tokio::runtime::Builder; use tokio::sync::{broadcast, Notify}; + use zaparoo_core::media_types::{MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH}; /// Build a `MediaImageCache` without spawning the fetch driver. /// Lets tests exercise `enqueue` / `is_cached` / `is_negative` @@ -1978,6 +2130,58 @@ mod tests { assert!(matches!(outcome, FetchOutcome::ConnectionDown)); } + #[test] + fn unsupported_delivery_errors_are_detected_narrowly() { + assert!(is_unsupported_local_path_error( + "invalid params: json: unknown field delivery" + )); + assert!(is_unsupported_local_path_error( + "media.image: unsupported delivery localPath" + )); + assert!(!is_unsupported_local_path_error("connection reset by peer")); + assert!(!is_unsupported_local_path_error("stale media id")); + } + + #[test] + fn local_path_bytes_use_existing_image_validation() { + let key = MediaKey::new("SNES", "/p"); + let image = MediaImageResult { + delivery: MEDIA_IMAGE_DELIVERY_LOCAL_PATH.to_string(), + content_type: "image/webp".to_string(), + extension: Some("webp".to_string()), + type_tag: "property:image-boxart".to_string(), + ..MediaImageResult::default() + }; + let outcome = classify_media_image_bytes(&key, &image, vec![1, 2, 3]); + assert!(matches!( + outcome, + FetchOutcome::Success { + ext: "webp", + type_tag, + .. + } if type_tag == "boxart" + )); + } + + #[test] + fn local_path_read_accepts_bytes_and_rejects_missing_files() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + file.write_all(&[1, 2, 3]).expect("write temp image"); + let existing = file.path().to_string_lossy().into_owned(); + let missing = file + .path() + .with_extension("missing") + .to_string_lossy() + .into_owned(); + let runtime = Builder::new_current_thread().build().expect("runtime"); + + assert_eq!( + runtime.block_on(read_local_image(existing)).expect("read"), + vec![1, 2, 3] + ); + assert!(runtime.block_on(read_local_image(missing)).is_err()); + } + fn key(s: &str, p: &str) -> MediaKey { MediaKey::new(s, p) } diff --git a/rust/frontend/src/models/alternate_versions.rs b/rust/frontend/src/models/alternate_versions.rs index 9d976a71..50af0762 100644 --- a/rust/frontend/src/models/alternate_versions.rs +++ b/rust/frontend/src/models/alternate_versions.rs @@ -265,6 +265,7 @@ fn media_item_from_browse_entry(entry: BrowseEntry) -> MediaItem { }, tags: entry.tags, disambiguating_tags: entry.disambiguating_tags, + has_cover: entry.has_cover, relative_path: if entry.relative_path.is_empty() { None } else { diff --git a/rust/frontend/src/models/favorites.rs b/rust/frontend/src/models/favorites.rs index 5e160560..fbab3d1b 100644 --- a/rust/frontend/src/models/favorites.rs +++ b/rust/frontend/src/models/favorites.rs @@ -956,17 +956,20 @@ impl ffi::FavoritesModel { let system = entry.system.id.clone(); let path = entry.path.clone(); let media_id = entry.media_id; + let has_cover = entry.has_cover; if system.trim().is_empty() || path.trim().is_empty() { clear_current_detail_state(self.as_mut()); return; } - let detail_key = match media_id { - Some(id) => MediaKey::with_media_id(system.clone(), path.clone(), id), - None => MediaKey::new(system.clone(), path.clone()), - } - .with_current_cover_preference(); - self.as_mut().rust_mut().current_detail_media_key = Some(detail_key); - self.as_mut().rust_mut().current_detail_media_id = media_id; + let detail_key = has_cover.then(|| { + match media_id { + Some(id) => MediaKey::with_media_id(system.clone(), path.clone(), id), + None => MediaKey::new(system.clone(), path.clone()), + } + .with_current_cover_preference() + }); + self.as_mut().rust_mut().current_detail_media_key = detail_key; + self.as_mut().rust_mut().current_detail_media_id = has_cover.then_some(media_id).flatten(); sync_current_detail_image_key(self.as_mut()); refresh_adjacent_cover_prefetch(self.as_mut()); @@ -1120,7 +1123,7 @@ fn emit_cover_key_range(mut model: Pin<&mut ffi::FavoritesModel>, first_row: i32 /// Build the canonical `(systemId, mediaPath)` identifier for a search /// row. Returns `None` for rows without enough info to key on. fn media_key_for(entry: &MediaItem) -> Option { - if entry.system.id.is_empty() || entry.path.is_empty() { + if !entry.has_cover || entry.system.id.is_empty() || entry.path.is_empty() { return None; } match entry.media_id { @@ -1932,6 +1935,14 @@ mod tests { ); } + #[test] + fn confirmed_no_cover_skips_key_and_first_paint_gate() { + let mut entry = favorite_entry(); + entry.has_cover = false; + assert!(media_key_for(&entry).is_none()); + assert!(compute_unresolved_keys(&[entry], |_| false, |_| false).is_empty()); + } + #[test] fn compute_unresolved_keys_excludes_soft_no_image() { let soft_key = MediaKey::new("SNES", "/games/favorite.rom"); diff --git a/rust/frontend/src/models/recents.rs b/rust/frontend/src/models/recents.rs index b58644dc..01970a82 100644 --- a/rust/frontend/src/models/recents.rs +++ b/rust/frontend/src/models/recents.rs @@ -17,9 +17,10 @@ // ticket that disarms stale callbacks. // // History is flat (no folder navigation, no auto-nav) so this model -// stays a fraction of the size of `GamesModel`. Rows are deduplicated -// by exact `mediaPath`; Core returns newest-first history, so the first -// row for a path is the one shown. Card-write isn't wired here yet — +// stays a fraction of the size of `GamesModel`. Every page asks Core for +// newest-session-per-`(systemId, mediaPath)` rows; a matching defensive +// filter only protects the model from malformed or older responses. +// Card-write isn't wired here yet — // recents launches by `run`-ing the entry's launcher route. use crate::media_image_cache::{global_media_image_cache, MediaImageCache, MediaKey}; @@ -362,7 +363,7 @@ fn apply_state( model.as_mut().rust_mut().seq.fetch_add(1, Ordering::SeqCst); model.as_mut().ensure_cover_subscription(); let raw_len = entries.len(); - let entries = dedupe_latest_by_path(entries); + let entries = dedupe_latest_by_identity(entries); info!( raw_len, deduped_len = entries.len(), @@ -642,6 +643,7 @@ impl ffi::RecentsModel { limit: Some(PAGE_SIZE), cursor: None, systems: Vec::new(), + distinct_media: Some(true), }) .await; match &result { @@ -723,6 +725,7 @@ impl ffi::RecentsModel { limit: Some(PAGE_SIZE), cursor, systems: Vec::new(), + distinct_media: Some(true), }) .await; let _ = qt_thread.queue(move |model| { @@ -888,17 +891,20 @@ impl ffi::RecentsModel { let system = entry.system_id.clone(); let path = entry.media_path.clone(); let media_id = entry.media_id; + let has_cover = entry.has_cover; if system.trim().is_empty() || path.trim().is_empty() { clear_current_detail_state(self.as_mut()); return; } - let detail_key = match media_id { - Some(id) => MediaKey::with_media_id(system.clone(), path.clone(), id), - None => MediaKey::new(system.clone(), path.clone()), - } - .with_current_cover_preference(); - self.as_mut().rust_mut().current_detail_media_key = Some(detail_key); - self.as_mut().rust_mut().current_detail_media_id = media_id; + let detail_key = has_cover.then(|| { + match media_id { + Some(id) => MediaKey::with_media_id(system.clone(), path.clone(), id), + None => MediaKey::new(system.clone(), path.clone()), + } + .with_current_cover_preference() + }); + self.as_mut().rust_mut().current_detail_media_key = detail_key; + self.as_mut().rust_mut().current_detail_media_id = has_cover.then_some(media_id).flatten(); sync_current_detail_image_key(self.as_mut()); refresh_adjacent_cover_prefetch(self.as_mut()); @@ -1126,7 +1132,7 @@ fn emit_cover_key_range(mut model: Pin<&mut ffi::RecentsModel>, first_row: i32, /// Build the canonical `(systemId, mediaPath)` identifier for a history /// row. Returns `None` for rows without enough info to key on. fn media_key_for(entry: &MediaHistoryEntry) -> Option { - if entry.system_id.is_empty() || entry.media_path.is_empty() { + if !entry.has_cover || entry.system_id.is_empty() || entry.media_path.is_empty() { return None; } match entry.media_id { @@ -1682,16 +1688,15 @@ fn position_of_path(entries: &[MediaHistoryEntry], needle: &str) -> i32 { .map_or(-1, |i| i as i32) } -/// Keep the newest history row for each exact, non-empty path. Core -/// returns history newest-first, so preserving the first occurrence -/// implements latest-wins without parsing timestamps. Empty paths are -/// malformed/unlaunchable and stay as-is instead of all collapsing into -/// one bucket. -fn dedupe_latest_by_path(entries: Vec) -> Vec { - filter_entries_by_path(std::iter::empty::<&MediaHistoryEntry>(), entries) +/// Defensive duplicate filter for Core's `distinctMedia` response. Core owns +/// newest-session selection and cursor pagination; this only prevents a broken +/// or older response from duplicating the same `(systemId, mediaPath)` identity +/// in the model. Empty paths stay as-is instead of collapsing malformed rows. +fn dedupe_latest_by_identity(entries: Vec) -> Vec { + filter_entries_by_identity(std::iter::empty::<&MediaHistoryEntry>(), entries) } -fn filter_entries_by_path<'a, I>( +fn filter_entries_by_identity<'a, I>( existing_entries: I, incoming_entries: Vec, ) -> Vec @@ -1704,13 +1709,16 @@ where if entry.media_path.is_empty() { None } else { - Some(entry.media_path.clone()) + Some((entry.system_id.clone(), entry.media_path.clone())) } }) .collect::>(); incoming_entries .into_iter() - .filter(|entry| entry.media_path.is_empty() || seen.insert(entry.media_path.clone())) + .filter(|entry| { + entry.media_path.is_empty() + || seen.insert((entry.system_id.clone(), entry.media_path.clone())) + }) .collect() } @@ -1729,7 +1737,7 @@ fn apply_append_page( Ok(result) => { let has_next_page = result.has_next_page(); let next_cursor = result.next_cursor(); - let entries = filter_entries_by_path(model.entries.iter(), result.entries); + let entries = filter_entries_by_identity(model.entries.iter(), result.entries); let new_count = i32::try_from(entries.len()).unwrap_or(i32::MAX - model.count); if !model.cover_requests_paused { enqueue_recents_covers(&entries); @@ -1769,9 +1777,10 @@ mod tests { )] use super::{ - compute_unresolved_keys, cover_key_for_with, dedupe_latest_by_path, filter_entries_by_path, - launch_text_for, media_key_for, page_snapshot, position_of_path, resume_cover_key_for, - resume_entry, resume_entry_is_fresh, RESUME_FALLBACK_COVER_KEY, + compute_unresolved_keys, cover_key_for_with, dedupe_latest_by_identity, + filter_entries_by_identity, launch_text_for, media_key_for, page_snapshot, + position_of_path, resume_cover_key_for, resume_entry, resume_entry_is_fresh, + RESUME_FALLBACK_COVER_KEY, }; use crate::media_image_cache::{MediaImageCache, MediaKey}; use std::collections::HashSet; @@ -1913,6 +1922,14 @@ mod tests { ); } + #[test] + fn confirmed_no_cover_skips_key_and_first_paint_gate() { + let mut e = entry("smb", "/p/smb", "NES", "NES"); + e.has_cover = false; + assert!(media_key_for(&e).is_none()); + assert!(compute_unresolved_keys(&[e], |_| false, |_| false).is_empty()); + } + #[test] fn media_key_for_skips_rows_without_path_or_system() { let pathless = entry("ghost", "", "NES", "NES"); @@ -1960,9 +1977,10 @@ mod tests { } #[test] - fn dedupe_latest_by_path_keeps_first_matching_path() { - let entries = dedupe_latest_by_path(vec![ + fn dedupe_latest_by_identity_keeps_first_matching_system_and_path() { + let entries = dedupe_latest_by_identity(vec![ entry("latest smb", "/p/smb", "NES", "NES"), + entry("arcade twin", "/p/smb", "Arcade", "Arcade"), entry("zelda", "/p/zelda", "NES", "NES"), entry("older smb", "/p/smb", "NES", "NES"), entry("metroid", "/p/metroid", "NES", "NES"), @@ -1971,13 +1989,13 @@ mod tests { .iter() .map(|entry| entry.media_name.as_str()) .collect::>(); - assert_eq!(names, vec!["latest smb", "zelda", "metroid"]); + assert_eq!(names, vec!["latest smb", "arcade twin", "zelda", "metroid"]); } #[test] - fn filter_entries_by_path_skips_existing_and_later_incoming_duplicates() { + fn filter_entries_by_identity_skips_existing_and_later_incoming_duplicates() { let existing = [entry("latest smb", "/p/smb", "NES", "NES")]; - let entries = filter_entries_by_path( + let entries = filter_entries_by_identity( existing.iter(), vec![ entry("older smb", "/p/smb", "NES", "NES"), @@ -1994,8 +2012,8 @@ mod tests { } #[test] - fn dedupe_latest_by_path_preserves_empty_paths() { - let entries = dedupe_latest_by_path(vec![ + fn dedupe_latest_by_identity_preserves_empty_paths() { + let entries = dedupe_latest_by_identity(vec![ entry("ghost one", "", "NES", "NES"), entry("smb", "/p/smb", "NES", "NES"), entry("ghost two", "", "NES", "NES"), diff --git a/rust/mock-core/src/fixtures.rs b/rust/mock-core/src/fixtures.rs index 87224ba9..4753c041 100644 --- a/rust/mock-core/src/fixtures.rs +++ b/rust/mock-core/src/fixtures.rs @@ -278,6 +278,7 @@ pub fn media_browse_response(params: &Value) -> Value { "relativePath": file, "tags": tags_for(file, index), "disambiguatingTags": disambiguating_tags_for(file), + "hasCover": true, }); filters .iter() @@ -394,6 +395,7 @@ pub fn media_history_response(params: &Value) -> Value { "mediaName": name, "mediaPath": format!("/mock/{system}/{file}"), "launcherId": system, + "hasCover": true, "startedAt": started, "endedAt": ended, "playTime": 1800, @@ -449,6 +451,7 @@ fn games_for_systems<'a>(systems: &'a [&'a str]) -> impl Iterator "system": { "id": system, "name": system_name, "category": category }, "tags": tags_for(file, index), "disambiguatingTags": disambiguating_tags_for(file), + "hasCover": true, })) }) } diff --git a/rust/mock-core/src/handler.rs b/rust/mock-core/src/handler.rs index 4c526044..7037f96f 100644 --- a/rust/mock-core/src/handler.rs +++ b/rust/mock-core/src/handler.rs @@ -189,6 +189,7 @@ mod tests { assert!(results .iter() .all(|g| g["system"]["id"].as_str() == Some("NES"))); + assert!(results.iter().all(|g| g["hasCover"].is_boolean())); } #[test] @@ -382,7 +383,7 @@ mod tests { #[test] fn media_history_returns_entries_with_pagination() { - let req = r#"{"jsonrpc":"2.0","id":"1","method":"media.history","params":{"limit":5}}"#; + let req = r#"{"jsonrpc":"2.0","id":"1","method":"media.history","params":{"limit":5,"distinctMedia":true}}"#; let resp = parse(&dispatch(req)); let entries = resp["result"]["entries"].as_array().expect("array"); assert!(!entries.is_empty()); @@ -392,6 +393,7 @@ mod tests { assert!(entry["systemId"].is_string()); assert!(entry["systemName"].is_string()); assert!(entry["launcherId"].is_string()); + assert!(entry["hasCover"].is_boolean()); } let pagination = resp["result"]["pagination"] .as_object() diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index c1a9c35c..b43767d3 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -641,9 +641,9 @@ impl Client { /// Identified by `mediaId` when available, otherwise `(system, /// path)` where `path` is the canonical indexed media path returned /// by `media.search` or `media.browse`. Returns the `media.image` - /// payload: content type, file extension (when - /// derivable), base64 image bytes, and the resolved property type - /// tag. + /// payload: actual delivery mode, content type, file extension (when + /// derivable), inline base64 bytes or an opaque local thumbnail path, + /// and the resolved property type tag. pub async fn media_image( &self, params: MediaImageParams, diff --git a/rust/zaparoo-core/src/endpoints/media_history.rs b/rust/zaparoo-core/src/endpoints/media_history.rs index 9fc33f1d..af2dd68e 100644 --- a/rust/zaparoo-core/src/endpoints/media_history.rs +++ b/rust/zaparoo-core/src/endpoints/media_history.rs @@ -53,6 +53,7 @@ impl Endpoint for MediaHistoryEndpoint { limit: Some(args.limit), cursor: None, systems: args.systems, + distinct_media: Some(true), }) .await }) diff --git a/rust/zaparoo-core/src/media_types.rs b/rust/zaparoo-core/src/media_types.rs index f655752b..ea66c05e 100644 --- a/rust/zaparoo-core/src/media_types.rs +++ b/rust/zaparoo-core/src/media_types.rs @@ -92,7 +92,7 @@ pub struct TagInfo { pub label: String, } -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaItem { /// Opaque media database row ID. Treat as ephemeral — valid only @@ -120,6 +120,27 @@ pub struct MediaItem { /// indexed root). #[serde(default)] pub relative_path: Option, + /// False only when Core has confirmed no media- or title-level image + /// property. Missing on older Core builds, where the frontend must still + /// probe `media.image`. + #[serde(default = "default_true")] + pub has_cover: bool, +} + +impl Default for MediaItem { + fn default() -> Self { + Self { + media_id: None, + name: String::new(), + path: String::new(), + zap_script: String::new(), + system: System::default(), + tags: Vec::new(), + disambiguating_tags: Vec::new(), + relative_path: None, + has_cover: true, + } + } } /// System sub-object returned by `media.search`/`media.lookup`. Mirrors @@ -417,13 +438,17 @@ pub struct MediaHistoryParams { pub cursor: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub systems: Vec, + /// Ask Core to return only the newest session for each + /// `(systemId, mediaPath)` identity. + #[serde(skip_serializing_if = "Option::is_none")] + pub distinct_media: Option, } /// One entry in `media.history`. Field shapes mirror Core's docs; we /// don't need `started_at`/`ended_at`/`play_time` for the launch UI yet /// but keep them deserialised so future "most-played" / "last-played" /// captions don't need a re-roundtrip. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaHistoryEntry { /// Opaque media database row ID. Omitted when the history path @@ -449,6 +474,28 @@ pub struct MediaHistoryEntry { pub ended_at: Option, #[serde(default)] pub play_time: u64, + /// False only when Core has confirmed no media- or title-level image + /// property. Missing on older Core builds, where the frontend must still + /// probe `media.image`. + #[serde(default = "default_true")] + pub has_cover: bool, +} + +impl Default for MediaHistoryEntry { + fn default() -> Self { + Self { + media_id: None, + system_id: String::new(), + system_name: String::new(), + media_name: String::new(), + media_path: String::new(), + launcher_id: String::new(), + started_at: String::new(), + ended_at: None, + play_time: 0, + has_cover: true, + } + } } /// Response envelope for `media.history`. Pagination is "only present @@ -497,6 +544,9 @@ pub struct MediaHistoryLatestResult { pub entry: Option, } +pub const MEDIA_IMAGE_DELIVERY_INLINE: &str = "inline"; +pub const MEDIA_IMAGE_DELIVERY_LOCAL_PATH: &str = "localPath"; + /// Parameters for single-image `media.image`. Core identifies the /// media row by `media_id` when available, otherwise by `(system, /// path)` where `path` is the canonical indexed media path returned by @@ -518,6 +568,10 @@ pub struct MediaImageParams { /// returning it. Omit (or send 0) to receive the full-resolution blob. #[serde(skip_serializing_if = "Option::is_none")] pub max_size: Option, + /// Requested delivery mode. Omitted for the established inline response; + /// `localPath` asks Core for an opaque cached-thumbnail path. + #[serde(skip_serializing_if = "Option::is_none")] + pub delivery: Option, } impl MediaImageParams { @@ -528,6 +582,7 @@ impl MediaImageParams { path: path.into(), image_types: Vec::new(), max_size: None, + delivery: None, } } @@ -538,6 +593,7 @@ impl MediaImageParams { path: String::new(), image_types: Vec::new(), max_size: None, + delivery: None, } } } @@ -545,6 +601,9 @@ impl MediaImageParams { #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaImageResult { + /// Actual delivery mode. Empty means a legacy Core inline response. + #[serde(default)] + pub delivery: String, #[serde(default)] pub content_type: String, /// File extension without a leading dot, derived by Core from the @@ -553,9 +612,12 @@ pub struct MediaImageResult { /// `content_type` or the binary payload. #[serde(default)] pub extension: Option, - /// Base64-encoded image bytes. + /// Base64-encoded image bytes, present for inline delivery. #[serde(default)] pub data: String, + /// Opaque Core-host thumbnail path, present for local-path delivery. + #[serde(default)] + pub local_path: Option, #[serde(default)] pub type_tag: String, } @@ -1188,13 +1250,14 @@ mod tests { use super::{ BrowseEntry, BrowseIndexGroup, HealthResult, IndexingStatusResponse, LaunchersResult, LogDownloadResult, MediaBrowseIndexParams, MediaBrowseIndexResult, MediaBrowseParams, - MediaBrowseResult, MediaHistoryLatestResult, MediaHistoryParams, MediaHistoryResult, - MediaHistoryTopParams, MediaHistoryTopResult, MediaImageParams, MediaImageResult, - MediaIndexParams, MediaLookupParams, MediaLookupResult, MediaMetaParams, MediaMetaResult, - MediaResult, MediaScrapeParams, MediaSearchParams, MediaSearchResult, MediaTagsParams, - MediaTagsResult, ReaderInfo, ReadersResult, ScrapersResult, ScrapingStatusResponse, - SettingsResult, SystemDefault, SystemsParams, SystemsResult, TagInfo, TokensHistoryResult, - TokensResult, UpdateSettingsParams, VersionResult, + MediaBrowseResult, MediaHistoryEntry, MediaHistoryLatestResult, MediaHistoryParams, + MediaHistoryResult, MediaHistoryTopParams, MediaHistoryTopResult, MediaImageParams, + MediaImageResult, MediaIndexParams, MediaItem, MediaLookupParams, MediaLookupResult, + MediaMetaParams, MediaMetaResult, MediaResult, MediaScrapeParams, MediaSearchParams, + MediaSearchResult, MediaTagsParams, MediaTagsResult, ReaderInfo, ReadersResult, + ScrapersResult, ScrapingStatusResponse, SettingsResult, SystemDefault, SystemsParams, + SystemsResult, TagInfo, TokensHistoryResult, TokensResult, UpdateSettingsParams, + VersionResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, }; #[test] @@ -1771,6 +1834,7 @@ mod tests { limit: Some(50), cursor: Some("opaque".into()), systems: vec!["SNES".into()], + distinct_media: Some(true), }; let json = serde_json::to_value(¶ms).expect("serialise"); let object = json.as_object().expect("object"); @@ -1789,6 +1853,12 @@ mod tests { .map(Vec::len), Some(1) ); + assert_eq!( + object + .get("distinctMedia") + .and_then(serde_json::Value::as_bool), + Some(true) + ); assert!(!object.contains_key("fuzzySystem")); } @@ -1804,12 +1874,14 @@ mod tests { ); assert!(!object.contains_key("imageTypes")); assert!(!object.contains_key("mediaId")); + assert!(!object.contains_key("delivery")); } #[test] - fn media_image_params_emits_image_types_when_set() { + fn media_image_params_emits_image_types_and_delivery_when_set() { let params = MediaImageParams { image_types: vec!["boxart".into(), "image".into()], + delivery: Some(MEDIA_IMAGE_DELIVERY_LOCAL_PATH.into()), ..MediaImageParams::for_media("SNES", "/p") }; let json = serde_json::to_value(¶ms).expect("serialise"); @@ -1820,6 +1892,10 @@ mod tests { .expect("imageTypes array"); assert_eq!(arr.len(), 2); assert_eq!(arr[0].as_str(), Some("boxart")); + assert_eq!( + object.get("delivery").and_then(|v| v.as_str()), + Some(MEDIA_IMAGE_DELIVERY_LOCAL_PATH) + ); } #[test] @@ -1854,9 +1930,11 @@ mod tests { "typeTag":"property:image-boxart" }"#; let result: MediaImageResult = serde_json::from_str(json).expect("parse"); + assert_eq!(result.delivery, ""); assert_eq!(result.content_type, "image/png"); assert_eq!(result.extension.as_deref(), Some("png")); assert_eq!(result.data, "iVBORw0KGgo="); + assert!(result.local_path.is_none()); assert_eq!(result.type_tag, "property:image-boxart"); } @@ -2485,6 +2563,37 @@ mod tests { assert!(result.media.title.properties.is_empty()); } + #[test] + fn search_and_history_cover_defaults_are_backward_compatible() { + let item: MediaItem = + serde_json::from_str(r#"{"name":"Zelda","path":"/p"}"#).expect("search item"); + let history: MediaHistoryEntry = + serde_json::from_str(r#"{"mediaPath":"/p"}"#).expect("history entry"); + assert!(item.has_cover); + assert!(history.has_cover); + assert!(MediaItem::default().has_cover); + assert!(MediaHistoryEntry::default().has_cover); + + let item: MediaItem = + serde_json::from_str(r#"{"name":"Zelda","path":"/p","hasCover":false}"#) + .expect("search item"); + let history: MediaHistoryEntry = + serde_json::from_str(r#"{"mediaPath":"/p","hasCover":false}"#).expect("history entry"); + assert!(!item.has_cover); + assert!(!history.has_cover); + } + + #[test] + fn media_image_result_parses_local_path_delivery() { + let result: MediaImageResult = serde_json::from_str( + r#"{"delivery":"localPath","contentType":"image/webp","extension":"webp","localPath":"/tmp/cover.webp","typeTag":"property:image-boxart"}"#, + ) + .expect("parse"); + assert_eq!(result.delivery, MEDIA_IMAGE_DELIVERY_LOCAL_PATH); + assert_eq!(result.local_path.as_deref(), Some("/tmp/cover.webp")); + assert!(result.data.is_empty()); + } + #[test] fn browse_entry_has_cover_absent_defaults_to_true() { // Older Core builds don't send `hasCover`; the frontend must From 8e5d0d7739f1ac1f56d7160dfeecb573408dc778 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 15:19:46 +0800 Subject: [PATCH 2/9] perf(qml): suspend hidden grid delegates Co-authored-by: Giancarlo Erra --- src/ui/components/PagedGrid.qml | 15 ++++- src/ui/screens/MediaListScreen.qml | 1 + tests/ui/tst_paged_grid.qml | 98 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/ui/components/PagedGrid.qml b/src/ui/components/PagedGrid.qml index 4ec3b194..e0307f1a 100644 --- a/src/ui/components/PagedGrid.qml +++ b/src/ui/components/PagedGrid.qml @@ -45,7 +45,12 @@ Item { required property Component delegate property int currentIndex: 0 - readonly property int itemCount: itemRepeater.count + // List layout keeps this grid as cursor/page authority while rendering a + // separate row view. Removing the hidden Repeater model avoids one cell + // object and role-binding set per loaded row; count still follows source + // model so navigation math remains unchanged. + property bool suspendDelegates: false + readonly property int itemCount: root.suspendDelegates ? (root.model && root.model.count !== undefined ? root.model.count : 0) : itemRepeater.count // Whether this section currently owns user focus. Tile uses this to // gate the selection card so only one section shows the focus cue @@ -580,7 +585,11 @@ Item { } onItemCountChanged: { - if (root.itemCount < root._previousItemCount) { + // Destroying the Repeater during suspension briefly reports zero before + // itemCount rebinds to source count. That is not model shrinkage and + // must not reset restored list selection. + const sourceCountRetained = root.model && root.model.count >= root._previousItemCount; + if (root.itemCount < root._previousItemCount && !sourceCountRetained) { // Model shed rows (reset, system change, path change). The // pending-target context no longer applies — drop it before // the row-count check below moves currentIndex. @@ -618,7 +627,7 @@ Item { Repeater { id: itemRepeater - model: root.model + model: root.suspendDelegates ? null : root.model Item { id: cellItem diff --git a/src/ui/screens/MediaListScreen.qml b/src/ui/screens/MediaListScreen.qml index 9891af22..3eb5e2d9 100644 --- a/src/ui/screens/MediaListScreen.qml +++ b/src/ui/screens/MediaListScreen.qml @@ -513,6 +513,7 @@ Item { PagedGrid { id: mediaGrid + suspendDelegates: root._listLayout visible: !root._gateHide && !root._listLayout && root.renderGridLayout anchors.left: parent.left anchors.right: parent.right diff --git a/tests/ui/tst_paged_grid.qml b/tests/ui/tst_paged_grid.qml index e62cdb71..a8586c23 100644 --- a/tests/ui/tst_paged_grid.qml +++ b/tests/ui/tst_paged_grid.qml @@ -58,6 +58,70 @@ TestCase { signalName: "loadMoreRequested" } + property int suspendLiveDelegates: 0 + + ListModel { + id: suspendModel + ListElement { + name: "a" + coverKey: "" + favorite: 0 + hidden: false + disambiguatingTags: "" + } + ListElement { + name: "b" + coverKey: "" + favorite: 0 + hidden: false + disambiguatingTags: "" + } + ListElement { + name: "c" + coverKey: "" + favorite: 0 + hidden: false + disambiguatingTags: "" + } + } + + ListModel { + id: suspendReplacementModel + ListElement { + name: "replacement" + coverKey: "" + favorite: 0 + hidden: false + disambiguatingTags: "" + } + } + + Component { + id: suspendDelegate + Item { + property string name: "" + property string coverKey: "" + property bool isSelected: false + property bool isFocused: false + property int favorite: 0 + property bool hidden: false + property string disambiguatingTags: "" + Component.onCompleted: testCase.suspendLiveDelegates++ + Component.onDestruction: testCase.suspendLiveDelegates-- + } + } + + PagedGrid { + id: suspendProbe + suspendDelegates: true + model: suspendModel + delegate: suspendDelegate + columnsOverride: 3 + rowsOverride: 3 + width: 300 + height: 300 + } + function fillModel(count: int): void { model.clear(); for (let i = 0; i < count; i++) @@ -83,6 +147,40 @@ TestCase { loadMoreSpy.clear(); } + function test_suspended_delegates_track_model_without_materializing(): void { + compare(suspendProbe.itemCount, 3); + compare(testCase.suspendLiveDelegates, 0); + + suspendModel.append({ + "name": "d", + "coverKey": "", + "favorite": 0, + "hidden": false, + "disambiguatingTags": "" + }); + tryCompare(suspendProbe, "itemCount", 4); + compare(testCase.suspendLiveDelegates, 0); + + suspendProbe.setCurrentIndexImmediate(2); + suspendProbe.suspendDelegates = false; + tryCompare(testCase, "suspendLiveDelegates", 4); + compare(suspendProbe.currentIndex, 2); + + suspendProbe.suspendDelegates = true; + tryCompare(testCase, "suspendLiveDelegates", 0); + compare(suspendProbe.itemCount, 4); + compare(suspendProbe.currentIndex, 2); + + suspendProbe.model = suspendReplacementModel; + tryCompare(suspendProbe, "itemCount", 1); + compare(testCase.suspendLiveDelegates, 0); + + suspendProbe.model = suspendModel; + suspendModel.remove(3); + tryCompare(suspendProbe, "itemCount", 3); + suspendProbe.setCurrentIndexImmediate(0); + } + function test_geometry_matches_pinned_resolution(): void { compare(grid.columns, 4, "expected 4 columns at 480px height"); compare(grid.rows, 3, "expected 3 rows at 480px height"); From 370cee56d8663029d7e36c8b7880f5a7164b5c6e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 15 Aug 2026 16:00:27 +0800 Subject: [PATCH 3/9] perf(media): batch detail metadata prefetch --- rust/frontend/src/media_meta_cache.rs | 408 ++++++++++++++++++++++---- rust/frontend/src/models/favorites.rs | 18 +- rust/frontend/src/models/games.rs | 225 +++++++++----- rust/frontend/src/models/recents.rs | 18 +- rust/mock-core/src/fixtures.rs | 58 ++++ rust/mock-core/src/handler.rs | 12 + rust/zaparoo-core/src/client.rs | 24 +- rust/zaparoo-core/src/media_types.rs | 92 +++++- 8 files changed, 700 insertions(+), 155 deletions(-) diff --git a/rust/frontend/src/media_meta_cache.rs b/rust/frontend/src/media_meta_cache.rs index 4cafbd6f..6acd061e 100644 --- a/rust/frontend/src/media_meta_cache.rs +++ b/rust/frontend/src/media_meta_cache.rs @@ -19,20 +19,24 @@ // In-memory only and strictly bounded by an LRU cap — Core is the canonical // metadata store and the frontend must not persist scraped metadata, nor grow // without bound on MiSTer's tight RAM budget (see CLAUDE.md). Metadata is a -// handful of small strings per row, so the entry-count cap keeps the footprint -// well under a megabyte. +// variable-sized strings and collections, so every entry is conservatively +// byte-accounted under a hard 4 MiB cap. use std::collections::{HashMap, HashSet}; +use std::mem::size_of; use std::sync::{Arc, Mutex, OnceLock}; use tracing::debug; -use zaparoo_core::media_types::{MediaMeta, MediaMetaParams}; +use zaparoo_core::media_types::{ + MediaMeta, MediaMetaBatchResult, MediaMetaParams, MediaMetaProperty, TagInfo, + MEDIA_META_BATCH_MAX_ITEMS, +}; use crate::media_image_cache::MediaKey; use crate::models::{global_handle, global_store}; -/// Maximum number of cached metadata entries before LRU eviction kicks in. -const META_CACHE_CAP: usize = 512; +const META_CACHE_CAP_BYTES: usize = 4 * 1024 * 1024; +const HASH_ENTRY_OVERHEAD: usize = 16; /// Outcome of a synchronous cache probe. pub enum MetaLookup { @@ -49,15 +53,19 @@ pub enum MetaLookup { struct Entry { /// `Some` = positive hit, `None` = memoized negative. meta: Option, + /// Conservative owned-byte weight, including key and hash-entry overhead. + weight: usize, /// LRU recency stamp, bumped on insert and on every read. clock: u64, } struct State { map: HashMap, - /// Keys with a prefetch fetch in flight, so concurrent prefetch passes do - /// not double-request the same row. + /// Keys with a prefetch fetch in flight, so overlapping windows do not + /// request the same row twice. inflight: HashSet, + used_bytes: usize, + cap_bytes: usize, clock: u64, } @@ -67,10 +75,16 @@ pub struct MediaMetaCache { impl MediaMetaCache { fn new() -> Self { + Self::new_with_cap(META_CACHE_CAP_BYTES) + } + + fn new_with_cap(cap_bytes: usize) -> Self { Self { state: Mutex::new(State { map: HashMap::new(), inflight: HashSet::new(), + used_bytes: 0, + cap_bytes, clock: 0, }), } @@ -95,67 +109,222 @@ impl MediaMetaCache { } /// Insert a resolved fetch outcome. `Some` is a positive hit, `None` a - /// negative memo. Evicts the least-recently-used entry past the cap. + /// negative memo. Replacements update byte accounting, oversized entries + /// are rejected, and least-recently-used entries are evicted to the cap. pub fn store(&self, key: MediaKey, meta: Option) { #[allow(clippy::unwrap_used, reason = "Mutex poisoning is unrecoverable")] let mut guard = self.state.lock().unwrap(); store_locked(&mut guard, key, meta); } - /// Best-effort background warm of `requests` (key + params) that are not - /// already cached or in flight. Fire-and-forget: results land in the cache - /// for the next synchronous `lookup`. + /// Best-effort background warm of uncached neighbors. One ordered batch is + /// issued after cached/in-flight filtering; results become synchronous hits + /// for the next focus move. pub fn prefetch(&self, requests: Vec<(MediaKey, MediaMetaParams)>) { - let mut to_fetch: Vec<(MediaKey, MediaMetaParams)> = Vec::new(); - { - #[allow(clippy::unwrap_used, reason = "Mutex poisoning is unrecoverable")] - let mut guard = self.state.lock().unwrap(); - for (key, params) in requests { - if guard.map.contains_key(&key) || guard.inflight.contains(&key) { - continue; - } - guard.inflight.insert(key.clone()); - to_fetch.push((key, params)); - } - } + let to_fetch = self.prepare_prefetch(requests); if to_fetch.is_empty() { return; } global_handle().spawn(async move { - let store = global_store(); + let (keys, params): (Vec<_>, Vec<_>) = to_fetch.into_iter().unzip(); + let result = global_store().client().media_meta_batch(params).await; let cache = global_media_meta_cache(); - for (key, params) in to_fetch { - let meta = match store.client().media_meta(params).await { - Ok(result) => Some(result.media), - Err(_) => None, - }; - cache.store(key, meta); + match result { + Ok(batch) => cache.finish_prefetch(keys, Some(batch)), + Err(error) => { + debug!(error = %error.message, "media_meta_cache: batch prefetch failed"); + cache.finish_prefetch(keys, None); + } } }); } + + fn prepare_prefetch( + &self, + requests: Vec<(MediaKey, MediaMetaParams)>, + ) -> Vec<(MediaKey, MediaMetaParams)> { + #[allow(clippy::unwrap_used, reason = "Mutex poisoning is unrecoverable")] + let mut guard = self.state.lock().unwrap(); + let mut to_fetch = Vec::new(); + for (key, params) in requests { + if guard.map.contains_key(&key) || guard.inflight.contains(&key) { + continue; + } + guard.inflight.insert(key.clone()); + to_fetch.push((key, params)); + if to_fetch.len() == MEDIA_META_BATCH_MAX_ITEMS { + break; + } + } + to_fetch + } + + /// Apply ordered results. Transport/protocol failures release every key + /// without a negative memo so later focused fetches can retry. + fn finish_prefetch(&self, keys: Vec, result: Option) { + #[allow(clippy::unwrap_used, reason = "Mutex poisoning is unrecoverable")] + let mut guard = self.state.lock().unwrap(); + let Some(batch) = result else { + clear_inflight_locked(&mut guard, &keys); + return; + }; + if batch.items.len() != keys.len() { + debug!( + expected = keys.len(), + actual = batch.items.len(), + "media_meta_cache: malformed batch response length" + ); + clear_inflight_locked(&mut guard, &keys); + return; + } + for (key, item) in keys.into_iter().zip(batch.items) { + match (item.media, item.error) { + (Some(meta), None) => store_locked(&mut guard, key, Some(meta)), + (None, Some(_)) => store_locked(&mut guard, key, None), + _ => { + guard.inflight.remove(&key); + debug!("media_meta_cache: unmatched batch item"); + } + } + } + } +} + +fn clear_inflight_locked(guard: &mut State, keys: &[MediaKey]) { + for key in keys { + guard.inflight.remove(key); + } } fn store_locked(guard: &mut State, key: MediaKey, meta: Option) { + guard.inflight.remove(&key); + if let Some(previous) = guard.map.remove(&key) { + guard.used_bytes = guard.used_bytes.saturating_sub(previous.weight); + } + + let weight = entry_weight(&key, meta.as_ref()); + if weight > guard.cap_bytes { + debug!( + weight, + cap = guard.cap_bytes, + "media_meta_cache: rejected oversized entry" + ); + return; + } + guard.clock += 1; let now = guard.clock; - guard.inflight.remove(&key); - guard.map.insert(key, Entry { meta, clock: now }); - while guard.map.len() > META_CACHE_CAP { + guard.used_bytes = guard.used_bytes.saturating_add(weight); + guard.map.insert( + key, + Entry { + meta, + weight, + clock: now, + }, + ); + while guard.used_bytes > guard.cap_bytes { let victim = guard .map .iter() .min_by_key(|(_, entry)| entry.clock) - .map(|(k, _)| k.clone()); - match victim { - Some(v) => { - guard.map.remove(&v); - debug!("media_meta_cache: evicted entry"); - } - None => break, + .map(|(key, _)| key.clone()); + let Some(victim) = victim else { + break; + }; + if let Some(entry) = guard.map.remove(&victim) { + guard.used_bytes = guard.used_bytes.saturating_sub(entry.weight); + debug!("media_meta_cache: evicted entry"); } } } +fn entry_weight(key: &MediaKey, meta: Option<&MediaMeta>) -> usize { + size_of::() + .saturating_add(size_of::()) + .saturating_add(HASH_ENTRY_OVERHEAD) + .saturating_add(key.system_id.len()) + .saturating_add(key.path.len()) + .saturating_add(key.image_type.as_deref().map_or(0, str::len)) + .saturating_add(6 * size_of::()) + .saturating_add(meta.map_or(0, media_meta_heap_weight)) +} + +fn media_meta_heap_weight(meta: &MediaMeta) -> usize { + string_heap_weight(&meta.path) + .saturating_add(string_heap_weight(&meta.parent_dir)) + .saturating_add(tags_heap_weight(&meta.tags, meta.tags.capacity())) + .saturating_add(properties_heap_weight(&meta.properties)) + .saturating_add(strings_heap_weight( + &meta.available_image_types, + meta.available_image_types.capacity(), + )) + .saturating_add(string_heap_weight(&meta.title.slug)) + .saturating_add( + meta.title + .secondary_slug + .as_ref() + .map_or(0, string_heap_weight), + ) + .saturating_add(string_heap_weight(&meta.title.name)) + .saturating_add(string_heap_weight(&meta.title.system.id)) + .saturating_add(string_heap_weight(&meta.title.system.name)) + .saturating_add(tags_heap_weight( + &meta.title.tags, + meta.title.tags.capacity(), + )) + .saturating_add(properties_heap_weight(&meta.title.properties)) + .saturating_add(strings_heap_weight( + &meta.title.available_image_types, + meta.title.available_image_types.capacity(), + )) +} + +fn string_heap_weight(value: &String) -> usize { + value.capacity() +} + +fn strings_heap_weight(values: &[String], capacity: usize) -> usize { + capacity + .saturating_mul(size_of::()) + .saturating_add(values.iter().map(string_heap_weight).sum::()) +} + +fn tags_heap_weight(tags: &[TagInfo], capacity: usize) -> usize { + capacity + .saturating_mul(size_of::()) + .saturating_add( + tags.iter() + .map(|tag| { + string_heap_weight(&tag.tag) + .saturating_add(string_heap_weight(&tag.tag_type)) + .saturating_add(string_heap_weight(&tag.label)) + }) + .sum::(), + ) +} + +fn properties_heap_weight(properties: &HashMap) -> usize { + properties + .capacity() + .saturating_mul( + size_of::() + .saturating_add(size_of::()) + .saturating_add(HASH_ENTRY_OVERHEAD), + ) + .saturating_add( + properties + .iter() + .map(|(key, property)| { + string_heap_weight(key) + .saturating_add(string_heap_weight(&property.text)) + .saturating_add(string_heap_weight(&property.content_type)) + .saturating_add(property.extension.as_ref().map_or(0, string_heap_weight)) + }) + .sum::(), + ) +} + static GLOBAL_MEDIA_META_CACHE: OnceLock> = OnceLock::new(); /// Lazily initialise the process-wide media metadata cache and return a handle. @@ -170,11 +339,16 @@ pub fn global_media_meta_cache() -> Arc { #[cfg(test)] mod tests { use super::*; + use zaparoo_core::media_types::MediaMetaBatchItemResult; fn key(path: &str) -> MediaKey { MediaKey::new("SNES", path) } + fn params(path: &str) -> MediaMetaParams { + MediaMetaParams::for_media("SNES", path) + } + fn meta_with_path(path: &str) -> MediaMeta { MediaMeta { path: path.to_string(), @@ -182,6 +356,10 @@ mod tests { } } + fn prepared_keys(prepared: &[(MediaKey, MediaMetaParams)]) -> Vec { + prepared.iter().map(|(key, _)| key.clone()).collect() + } + #[test] fn positive_hit_round_trips() { let cache = MediaMetaCache::new(); @@ -203,21 +381,141 @@ mod tests { } #[test] - fn evicts_least_recently_used_past_cap() { + fn evicts_least_recently_used_to_byte_cap() { + let a = key("a"); + let b = key("b"); + let c = key("c"); + let meta_a = meta_with_path("a"); + let meta_b = meta_with_path("b"); + let cap = entry_weight(&a, Some(&meta_a)) + entry_weight(&b, Some(&meta_b)); + let cache = MediaMetaCache::new_with_cap(cap); + cache.store(a.clone(), Some(meta_a)); + cache.store(b.clone(), Some(meta_b)); + assert!(matches!(cache.lookup(&a), MetaLookup::Hit(_))); + cache.store(c.clone(), Some(meta_with_path("c"))); + assert!(matches!(cache.lookup(&b), MetaLookup::Miss)); + assert!(matches!(cache.lookup(&a), MetaLookup::Hit(_))); + assert!(matches!(cache.lookup(&c), MetaLookup::Hit(_))); + #[allow(clippy::unwrap_used, reason = "test mutex must remain healthy")] + let guard = cache.state.lock().unwrap(); + assert!(guard.used_bytes <= guard.cap_bytes); + } + + #[test] + fn replacement_updates_byte_weight() { + let cache = MediaMetaCache::new_with_cap(16 * 1024); + let cache_key = key("a"); + cache.store(cache_key.clone(), Some(meta_with_path("a"))); + #[allow(clippy::unwrap_used, reason = "test mutex must remain healthy")] + let before = cache.state.lock().unwrap().used_bytes; + let larger = meta_with_path(&"x".repeat(1024)); + let expected = entry_weight(&cache_key, Some(&larger)); + cache.store(cache_key, Some(larger)); + #[allow(clippy::unwrap_used, reason = "test mutex must remain healthy")] + let after = cache.state.lock().unwrap().used_bytes; + assert!(after > before); + assert_eq!(after, expected); + } + + #[test] + fn oversized_entry_is_not_cached() { + let cache = MediaMetaCache::new_with_cap(512); + let cache_key = key("large"); + cache.store(cache_key.clone(), Some(meta_with_path(&"x".repeat(2048)))); + assert!(matches!(cache.lookup(&cache_key), MetaLookup::Miss)); + #[allow(clippy::unwrap_used, reason = "test mutex must remain healthy")] + let guard = cache.state.lock().unwrap(); + assert_eq!(guard.used_bytes, 0); + } + + #[test] + fn mixed_batch_results_map_to_ordered_keys() { let cache = MediaMetaCache::new(); - for i in 0..META_CACHE_CAP { - cache.store( - key(&format!("k{i}")), - Some(meta_with_path(&format!("k{i}"))), - ); - } - // Touch k0 so it is the most-recently-used, then overflow by one. - assert!(matches!(cache.lookup(&key("k0")), MetaLookup::Hit(_))); - cache.store(key("overflow"), Some(meta_with_path("overflow"))); - // k1 was the least-recently-used and should have been evicted; k0 - // survived because the lookup refreshed its recency. - assert!(matches!(cache.lookup(&key("k1")), MetaLookup::Miss)); - assert!(matches!(cache.lookup(&key("k0")), MetaLookup::Hit(_))); - assert!(matches!(cache.lookup(&key("overflow")), MetaLookup::Hit(_))); + let prepared = + cache.prepare_prefetch(vec![(key("a"), params("a")), (key("b"), params("b"))]); + let keys = prepared_keys(&prepared); + cache.finish_prefetch( + keys, + Some(MediaMetaBatchResult { + items: vec![ + MediaMetaBatchItemResult { + media: Some(meta_with_path("a")), + error: None, + }, + MediaMetaBatchItemResult { + media: None, + error: Some("not found".into()), + }, + ], + }), + ); + assert!(matches!(cache.lookup(&key("a")), MetaLookup::Hit(meta) if meta.path == "a")); + assert!(matches!(cache.lookup(&key("b")), MetaLookup::Negative)); + } + + #[test] + fn transport_and_short_responses_release_without_poisoning() { + let cache = MediaMetaCache::new(); + let prepared = + cache.prepare_prefetch(vec![(key("a"), params("a")), (key("b"), params("b"))]); + cache.finish_prefetch(prepared_keys(&prepared), None); + assert!(matches!(cache.lookup(&key("a")), MetaLookup::Miss)); + assert_eq!( + cache.prepare_prefetch(vec![(key("a"), params("a"))]).len(), + 1 + ); + + let prepared = cache.prepare_prefetch(vec![(key("b"), params("b"))]); + cache.finish_prefetch( + prepared_keys(&prepared), + Some(MediaMetaBatchResult { items: Vec::new() }), + ); + assert!(matches!(cache.lookup(&key("b")), MetaLookup::Miss)); + assert_eq!( + cache.prepare_prefetch(vec![(key("b"), params("b"))]).len(), + 1 + ); + } + + #[test] + fn unmatched_batch_item_releases_without_negative_memo() { + let cache = MediaMetaCache::new(); + let prepared = cache.prepare_prefetch(vec![(key("a"), params("a"))]); + cache.finish_prefetch( + prepared_keys(&prepared), + Some(MediaMetaBatchResult { + items: vec![MediaMetaBatchItemResult::default()], + }), + ); + assert!(matches!(cache.lookup(&key("a")), MetaLookup::Miss)); + assert_eq!( + cache.prepare_prefetch(vec![(key("a"), params("a"))]).len(), + 1 + ); + } + + #[test] + fn overlapping_prefetch_windows_do_not_duplicate_requests() { + let cache = MediaMetaCache::new(); + let first = cache.prepare_prefetch(vec![(key("a"), params("a")), (key("b"), params("b"))]); + let second = cache.prepare_prefetch(vec![(key("b"), params("b")), (key("c"), params("c"))]); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 1); + assert_eq!(second[0].0.path.as_ref(), "c"); + } + + #[test] + fn prefetch_never_prepares_more_than_core_batch_cap() { + let cache = MediaMetaCache::new(); + let requests = (0..=MEDIA_META_BATCH_MAX_ITEMS) + .map(|i| { + let path = format!("{i}"); + (key(&path), params(&path)) + }) + .collect(); + assert_eq!( + cache.prepare_prefetch(requests).len(), + MEDIA_META_BATCH_MAX_ITEMS + ); } } diff --git a/rust/frontend/src/models/favorites.rs b/rust/frontend/src/models/favorites.rs index fbab3d1b..a7db0f81 100644 --- a/rust/frontend/src/models/favorites.rs +++ b/rust/frontend/src/models/favorites.rs @@ -998,11 +998,12 @@ impl ffi::FavoritesModel { let qt_thread = self.qt_thread(); let store = global_store(); let store_key = meta_key.clone(); + let meta_params = media_id.map_or_else( + || MediaMetaParams::for_media(system, path.clone()), + MediaMetaParams::for_media_id, + ); global_handle().spawn(async move { - let result = store - .client() - .media_meta(MediaMetaParams::for_media(system, path.clone())) - .await; + let result = store.client().media_meta(meta_params).await; // Cache the outcome (positive or negative) regardless of whether // this callback is still current, so a later revisit is instant. match &result { @@ -1196,10 +1197,11 @@ fn enqueue_meta_prefetch(entries: &[MediaItem], count: i32, row: i32) { if system.trim().is_empty() || path.trim().is_empty() { continue; } - requests.push(( - MediaKey::new(system.clone(), path.clone()), - MediaMetaParams::for_media(system, path), - )); + let params = entry.media_id.map_or_else( + || MediaMetaParams::for_media(system.clone(), path.clone()), + MediaMetaParams::for_media_id, + ); + requests.push((MediaKey::new(system, path), params)); } if !requests.is_empty() { global_media_meta_cache().prefetch(requests); diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 20953cf1..72ed9b8f 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -31,6 +31,7 @@ // when the user spams direction-arrow + Accept across a model swap. use crate::media_image_cache::{global_media_image_cache, MediaImageCache, MediaKey}; +use crate::media_meta_cache::{global_media_meta_cache, MetaLookup}; use crate::models::nav_timing::NavTiming; use crate::models::tag_utils::{ disambiguating_tag_labels, sibling_disambiguation_displays, tag_display_value, @@ -1219,9 +1220,9 @@ impl ffi::GamesModel { // Immediate, non-debounced sibling of `load_description_at`. Called the // moment the focused row changes so the detail pane reflects THIS row's // local metadata (description + entry tags) at once, instead of holding the - // previous row's values through the load debounce. The debounced - // `load_description_at` then enriches it from media.meta. Games entries - // carry their own tags, so this needs no metadata cache. + // previous row's values through the load debounce. A warm neighbor paints + // richer cached metadata immediately; a cold row keeps BrowseEntry values + // visible until debounced `load_description_at` performs its single fetch. fn peek_description_at(mut self: Pin<&mut Self>, index: i32) { self.as_mut() .rust_mut() @@ -1236,6 +1237,7 @@ impl ffi::GamesModel { } self.as_mut().rust_mut().detail_prefetch_row = Some(index); prefetch_cursor_window(&self, index); + enqueue_meta_prefetch(&self.entries, self.count, index); let entry = &self.entries[index as usize]; if !is_media_capable_entry(entry) { @@ -1248,14 +1250,41 @@ impl ffi::GamesModel { let description = entry.description.clone(); let detail_tags = detail_tags_from_entry(entry); - // Local tags paint immediately, so mark loading without blanking: the - // detail pane shows this row's values while the richer media.meta fetch - // is still pending. - self.as_mut().set_current_detail_loading(true); - self.as_mut() - .set_current_description(QString::from(description.as_str())); - self.as_mut() - .set_current_detail_tags(QString::from(detail_tags.as_str())); + let meta_key = meta_cache_key_for_entry(entry); + let lookup = meta_key.as_ref().map_or(MetaLookup::Miss, |key| { + global_media_meta_cache().lookup(key) + }); + match lookup { + MetaLookup::Hit(meta) => { + let rich_description = description_from_meta(&meta); + let visible_description = if rich_description.is_empty() { + description + } else { + rich_description + }; + self.as_mut() + .set_current_description(QString::from(visible_description.as_str())); + self.as_mut() + .set_current_detail_tags(QString::from(detail_tags_from_meta(&meta).as_str())); + self.as_mut().set_current_detail_loading(false); + } + MetaLookup::Negative => { + self.as_mut() + .set_current_description(QString::from(description.as_str())); + self.as_mut() + .set_current_detail_tags(QString::from(detail_tags.as_str())); + self.as_mut().set_current_detail_loading(false); + } + MetaLookup::Miss => { + // Preserve BrowseEntry metadata while the debounced focused + // request remains cold. + self.as_mut() + .set_current_description(QString::from(description.as_str())); + self.as_mut() + .set_current_detail_tags(QString::from(detail_tags.as_str())); + self.as_mut().set_current_detail_loading(true); + } + } // Deliberately do NOT switch the visible cover here. The cover has its // own grace-window hold (BrowseDetailPane coverHold) and is settled by // the debounced `load_description_at`. Re-pointing the 512px cover Image @@ -1311,6 +1340,7 @@ impl ffi::GamesModel { // pane requests the same cache entry that `prefetch_around` already // warmed for the focused row — instant paint with no hourglass. let detail_image_key = media_key_for(entry).map(MediaKey::with_current_cover_preference); + let meta_key = meta_cache_key_for_entry(entry); let Some(params) = meta_params_for_entry(entry) else { self.as_mut().set_current_detail_loading(false); self.as_mut() @@ -1330,76 +1360,40 @@ impl ffi::GamesModel { set_single_detail_image_key(self.as_mut(), detail_image_key); refresh_adjacent_cover_prefetch(self.as_mut()); + if let Some(key) = meta_key.as_ref() { + match global_media_meta_cache().lookup(key) { + MetaLookup::Hit(meta) => { + apply_games_detail_meta(self.as_mut(), index, &meta); + self.as_mut().set_current_detail_loading(false); + return; + } + MetaLookup::Negative => { + self.as_mut().set_current_detail_loading(false); + return; + } + MetaLookup::Miss => {} + } + } + let seq = self.rust().description_seq.clone(); let qt_thread = self.qt_thread(); let store = global_store(); global_handle().spawn(async move { let result = store.client().media_meta(params).await; + if let Some(key) = meta_key { + match &result { + Ok(result) => { + global_media_meta_cache().store(key, Some(result.media.clone())); + } + Err(_) => global_media_meta_cache().store(key, None), + } + } let _ = qt_thread.queue(move |mut model| { if seq.load(Ordering::SeqCst) != ticket { return; } match result { - Ok(result) => { - let meta = result.media; - let description = description_from_meta(&meta); - if !description.is_empty() { - model - .as_mut() - .set_current_description(QString::from(description.as_str())); - } - model.as_mut().set_current_detail_tags(QString::from( - detail_tags_from_meta(&meta).as_str(), - )); - let cover_key = media_key_for(&model.entries[index as usize]) - .map(MediaKey::with_current_cover_preference); - let type_keys = detail_image_keys_from_meta( - &meta, - meta.title.system.id.as_str(), - meta.path.as_str(), - ); - if type_keys.is_empty() { - // No alternate images — just the cover; clear any - // stale pending carousel from a previous selection. - model.as_mut().rust_mut().pending_carousel_keys = None; - let detail_keys = cover_key.into_iter().collect(); - set_detail_image_keys(model.as_mut(), detail_keys); - } else if MediaImageCache::current_cover_preference_type().is_none() { - // Auto preference: we need Core's resolved type_tag - // for index-0 to drop its twin from the carousel tail. - // Check the cache; if the cover is already warm the - // type is known and we can dedup now. If not, stash - // the candidate keys and let notify_cover_update finish - // once the cover lands. - let cache = global_media_image_cache(); - let resolved = cover_key - .as_ref() - .and_then(|k| cache.resolved_image_type(k)); - if resolved.is_some() || cover_key.is_none() { - // Cover already fetched — dedup immediately. - model.as_mut().rust_mut().pending_carousel_keys = None; - let detail_keys = ordered_detail_image_keys( - cover_key, - type_keys, - resolved.as_deref(), - ); - set_detail_image_keys(model.as_mut(), detail_keys); - } else { - // Cover still in-flight. Publish a single-image - // carousel now (no arrows) and stash the candidates - // so notify_cover_update can finalize once the type - // is known. - model.as_mut().rust_mut().pending_carousel_keys = Some(type_keys); - let detail_keys = cover_key.into_iter().collect(); - set_detail_image_keys(model.as_mut(), detail_keys); - } - } else { - // Explicit preference — existing dedup path. - model.as_mut().rust_mut().pending_carousel_keys = None; - let detail_keys = ordered_detail_image_keys(cover_key, type_keys, None); - set_detail_image_keys(model.as_mut(), detail_keys); - } - } + Ok(result) => apply_games_detail_meta(model.as_mut(), index, &result.media), Err(e) => warn!("games detail fetch failed: {}", e.message), } model.as_mut().set_current_detail_loading(false); @@ -1834,6 +1828,42 @@ fn meta_params_for_entry(entry: &BrowseEntry) -> Option { Some(MediaMetaParams::for_media(system_id, entry.path.clone())) } +fn meta_cache_key_for_entry(entry: &BrowseEntry) -> Option { + if !is_media_capable_entry(entry) { + return None; + } + let system_id = entry_system_id(entry); + if system_id.trim().is_empty() || entry.path.trim().is_empty() { + return None; + } + Some(MediaKey::new(system_id, entry.path.clone())) +} + +/// Warm only two rows either side of cursor. Cache identity remains canonical +/// `(systemId, path)` while request refs prefer Core's ephemeral media ID. +fn enqueue_meta_prefetch(entries: &[BrowseEntry], count: i32, row: i32) { + let mut requests = Vec::new(); + for delta in [-2_i32, -1, 1, 2] { + let index = row + delta; + if index < 0 || index >= count { + continue; + } + let Some(entry) = usize::try_from(index).ok().and_then(|i| entries.get(i)) else { + continue; + }; + let (Some(key), Some(params)) = ( + meta_cache_key_for_entry(entry), + meta_params_for_entry(entry), + ) else { + continue; + }; + requests.push((key, params)); + } + if !requests.is_empty() { + global_media_meta_cache().prefetch(requests); + } +} + fn singleton_directory_needs_launch_resolution(entry: &BrowseEntry) -> bool { entry.entry_type == "directory" && entry.media_id.is_some() } @@ -1917,6 +1947,49 @@ fn detail_image_keys_from_meta(meta: &MediaMeta, system: &str, path: &str) -> Ve /// `resolved_type` is Core's reported `type_tag` for the index-0 cover (the /// concrete type Core actually served). Pass `None` when it isn't known yet; /// the caller will call again once the cover fetch completes. +fn apply_games_detail_meta(mut model: Pin<&mut ffi::GamesModel>, index: i32, meta: &MediaMeta) { + let description = description_from_meta(meta); + if !description.is_empty() { + model + .as_mut() + .set_current_description(QString::from(description.as_str())); + } + model + .as_mut() + .set_current_detail_tags(QString::from(detail_tags_from_meta(meta).as_str())); + + let cover_key = usize::try_from(index) + .ok() + .and_then(|index| model.entries.get(index)) + .and_then(media_key_for) + .map(MediaKey::with_current_cover_preference); + let type_keys = + detail_image_keys_from_meta(meta, meta.title.system.id.as_str(), meta.path.as_str()); + if type_keys.is_empty() { + model.as_mut().rust_mut().pending_carousel_keys = None; + set_detail_image_keys(model, cover_key.into_iter().collect()); + return; + } + if MediaImageCache::current_cover_preference_type().is_some() { + model.as_mut().rust_mut().pending_carousel_keys = None; + let detail_keys = ordered_detail_image_keys(cover_key, type_keys, None); + set_detail_image_keys(model, detail_keys); + return; + } + + let resolved = cover_key + .as_ref() + .and_then(|key| global_media_image_cache().resolved_image_type(key)); + if resolved.is_some() || cover_key.is_none() { + model.as_mut().rust_mut().pending_carousel_keys = None; + let detail_keys = ordered_detail_image_keys(cover_key, type_keys, resolved.as_deref()); + set_detail_image_keys(model, detail_keys); + } else { + model.as_mut().rust_mut().pending_carousel_keys = Some(type_keys); + set_detail_image_keys(model, cover_key.into_iter().collect()); + } +} + fn ordered_detail_image_keys( cover_key: Option, type_keys: Vec, @@ -3595,9 +3668,9 @@ mod tests { detail_image_keys_from_meta, detail_tags_from_tags, display_name, display_title_for_entry, entry_system_id, favorites_tags, games_random_launch_text, is_media_capable_entry, is_strict_ancestor_path, jump_fetch_limit, media_capable_directory_browse_params, - media_key_for, meta_params_for_entry, ordered_detail_image_keys, position_of_game_path, - prefetch_around_plan, prefetch_cursor_window_plan, project_status, result_total_dirs, - run_text_for_entry, seeded_refetch_pagination_state, + media_key_for, meta_cache_key_for_entry, meta_params_for_entry, ordered_detail_image_keys, + position_of_game_path, prefetch_around_plan, prefetch_cursor_window_plan, project_status, + result_total_dirs, run_text_for_entry, seeded_refetch_pagination_state, singleton_directory_needs_launch_resolution, transform_entries, InitialAction, Projection, }; use super::{FETCH_MORE_RAPID_CHUNK_SIZE, JUMP_FETCH_CHUNK_SIZE}; @@ -4236,6 +4309,10 @@ mod tests { assert_eq!(params.media_id, Some(42)); assert!(params.system.is_empty()); assert!(params.path.is_empty()); + let cache_key = meta_cache_key_for_entry(&entry).expect("cache key"); + assert_eq!(cache_key.system_id.as_ref(), "PSX"); + assert_eq!(cache_key.path.as_ref(), "/roms/PSX/Game"); + assert!(cache_key.media_id.is_none()); let browse_params = media_capable_directory_browse_params(&entry).expect("browse params"); assert_eq!(browse_params.path, "/roms/PSX/Game"); assert_eq!(browse_params.systems, vec!["PSX".to_string()]); diff --git a/rust/frontend/src/models/recents.rs b/rust/frontend/src/models/recents.rs index 01970a82..72ad6efa 100644 --- a/rust/frontend/src/models/recents.rs +++ b/rust/frontend/src/models/recents.rs @@ -933,11 +933,12 @@ impl ffi::RecentsModel { let qt_thread = self.qt_thread(); let store = global_store(); let store_key = meta_key.clone(); + let meta_params = media_id.map_or_else( + || MediaMetaParams::for_media(system, path.clone()), + MediaMetaParams::for_media_id, + ); global_handle().spawn(async move { - let result = store - .client() - .media_meta(MediaMetaParams::for_media(system, path.clone())) - .await; + let result = store.client().media_meta(meta_params).await; // Cache the outcome (positive or negative) regardless of whether // this callback is still current, so a later revisit is instant. match &result { @@ -1221,10 +1222,11 @@ fn enqueue_meta_prefetch(entries: &[MediaHistoryEntry], count: i32, row: i32) { if system.trim().is_empty() || path.trim().is_empty() { continue; } - requests.push(( - MediaKey::new(system.clone(), path.clone()), - MediaMetaParams::for_media(system, path), - )); + let params = entry.media_id.map_or_else( + || MediaMetaParams::for_media(system.clone(), path.clone()), + MediaMetaParams::for_media_id, + ); + requests.push((MediaKey::new(system, path), params)); } if !requests.is_empty() { global_media_meta_cache().prefetch(requests); diff --git a/rust/mock-core/src/fixtures.rs b/rust/mock-core/src/fixtures.rs index 4753c041..65a069c2 100644 --- a/rust/mock-core/src/fixtures.rs +++ b/rust/mock-core/src/fixtures.rs @@ -314,6 +314,64 @@ pub fn media_browse_response(params: &Value) -> Value { }) } +pub fn media_meta_response(params: &Value) -> Value { + fn media_for(reference: &Value) -> Value { + let media_id = reference.get("mediaId").and_then(Value::as_i64); + let system = reference + .get("system") + .and_then(Value::as_str) + .unwrap_or("Mock"); + let path = reference.get("path").and_then(Value::as_str).map_or_else( + || format!("/mock/media/{}", media_id.unwrap_or_default()), + str::to_string, + ); + let name = path + .rsplit('/') + .next() + .unwrap_or("Mock Game") + .split('.') + .next() + .unwrap_or("Mock Game") + .to_string(); + json!({ + "path": path, + "parentDir": "/mock", + "isMissing": false, + "tags": [{"type": "region", "tag": "world"}], + "properties": {}, + "availableImageTypes": [], + "title": { + "slug": name.to_lowercase(), + "name": name, + "slugLength": name.len(), + "slugWordCount": name.split_whitespace().count(), + "system": {"id": system, "name": system_display_for(system)}, + "tags": [ + {"type": "year", "tag": "1994"}, + {"type": "developer", "tag": "Mock Studio"} + ], + "properties": { + "property:description": { + "text": format!("Mock metadata for {name}."), + "contentType": "" + } + }, + "availableImageTypes": [] + } + }) + } + + if let Some(items) = params.get("items").and_then(Value::as_array) { + return json!({ + "items": items + .iter() + .map(|item| json!({"media": media_for(item)})) + .collect::>() + }); + } + json!({"media": media_for(params)}) +} + pub fn media_browse_index_response(params: &Value) -> Value { let mut browse_params = params.clone(); browse_params["maxResults"] = json!(1000); diff --git a/rust/mock-core/src/handler.rs b/rust/mock-core/src/handler.rs index 7037f96f..4bf174cd 100644 --- a/rust/mock-core/src/handler.rs +++ b/rust/mock-core/src/handler.rs @@ -64,6 +64,7 @@ pub fn dispatch(text: &str) -> String { "media.search" => Some(fixtures::media_search_response(&req.params)), "media.browse" => Some(fixtures::media_browse_response(&req.params)), "media.browse.index" => Some(fixtures::media_browse_index_response(&req.params)), + "media.meta" => Some(fixtures::media_meta_response(&req.params)), "media.history" => Some(fixtures::media_history_response(&req.params)), "media.history.latest" => Some(fixtures::media_history_latest_response()), "run" => { @@ -359,6 +360,17 @@ mod tests { ); } + #[test] + fn media_meta_preserves_batch_order() { + let req = r#"{"jsonrpc":"2.0","id":"1","method":"media.meta","params":{"items":[{"system":"NES","path":"/games/first.nes"},{"mediaId":42}]}}"#; + let resp = parse(&dispatch(req)); + let items = resp["result"]["items"].as_array().expect("items"); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["media"]["path"], "/games/first.nes"); + assert_eq!(items[0]["media"]["title"]["system"]["id"], "NES"); + assert_eq!(items[1]["media"]["path"], "/mock/media/42"); + } + #[test] fn run_accepts_any_text_and_returns_null() { let req = diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index b43767d3..cc59d479 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -18,11 +18,11 @@ use crate::media_types::{ MediaBrowseIndexResult, MediaBrowseParams, MediaBrowseResult, MediaHistoryLatestResult, MediaHistoryParams, MediaHistoryResult, MediaHistoryTopParams, MediaHistoryTopResult, MediaImageParams, MediaImageResult, MediaIndexParams, MediaLookupParams, MediaLookupResult, - MediaMetaParams, MediaMetaResult, MediaResult, MediaScrapeParams, MediaSearchParams, - MediaSearchResult, MediaTagsParams, MediaTagsResult, MediaTagsUpdateParams, - MediaTagsUpdateResult, ReadersResult, ReadersWriteParams, RunParams, ScrapersResult, - ScrapingStatusResponse, SettingsResult, SystemsParams, SystemsResult, TokensHistoryResult, - TokensResult, UpdateSettingsParams, VersionResult, + MediaMetaBatchParams, MediaMetaBatchResult, MediaMetaParams, MediaMetaResult, MediaResult, + MediaScrapeParams, MediaSearchParams, MediaSearchResult, MediaTagsParams, MediaTagsResult, + MediaTagsUpdateParams, MediaTagsUpdateResult, ReadersResult, ReadersWriteParams, RunParams, + ScrapersResult, ScrapingStatusResponse, SettingsResult, SystemsParams, SystemsResult, + TokensHistoryResult, TokensResult, UpdateSettingsParams, VersionResult, }; use futures_util::{SinkExt, StreamExt}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -669,6 +669,20 @@ impl Client { deserialize_timed("media.meta", val) } + /// Fetches an ordered batch of metadata graphs with one `media` or `error` + /// result per input ref. The established single-item method remains + /// separate so focused cold misses keep their minimal wire shape. + pub async fn media_meta_batch( + &self, + items: Vec, + ) -> Result { + let params = MediaMetaBatchParams::try_new(items).map_err(|message| ClientError { + message: message.to_string(), + })?; + let val = self.call("media.meta", ¶ms).await?; + deserialize_timed("media.meta batch", val) + } + pub async fn media_history( &self, params: MediaHistoryParams, diff --git a/rust/zaparoo-core/src/media_types.rs b/rust/zaparoo-core/src/media_types.rs index ea66c05e..492777f8 100644 --- a/rust/zaparoo-core/src/media_types.rs +++ b/rust/zaparoo-core/src/media_types.rs @@ -655,12 +655,48 @@ impl MediaMetaParams { } } +pub const MEDIA_META_BATCH_MAX_ITEMS: usize = 100; + +/// Ordered batch request for `media.meta`. Core accepts one to 100 refs and +/// returns one response item in the same position for each ref. +#[derive(Debug, Clone, Serialize)] +pub struct MediaMetaBatchParams { + pub items: Vec, +} + +impl MediaMetaBatchParams { + pub fn try_new(items: Vec) -> Result { + if items.is_empty() { + return Err("media.meta batch must contain at least one item"); + } + if items.len() > MEDIA_META_BATCH_MAX_ITEMS { + return Err("media.meta batch cannot contain more than 100 items"); + } + Ok(Self { items }) + } +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaMetaResult { pub media: MediaMeta, } +/// One ordered batch result. Exactly one of `media` or `error` should be set. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MediaMetaBatchItemResult { + #[serde(default)] + pub media: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MediaMetaBatchResult { + #[serde(default)] + pub items: Vec, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaMeta { @@ -1253,11 +1289,11 @@ mod tests { MediaBrowseResult, MediaHistoryEntry, MediaHistoryLatestResult, MediaHistoryParams, MediaHistoryResult, MediaHistoryTopParams, MediaHistoryTopResult, MediaImageParams, MediaImageResult, MediaIndexParams, MediaItem, MediaLookupParams, MediaLookupResult, - MediaMetaParams, MediaMetaResult, MediaResult, MediaScrapeParams, MediaSearchParams, - MediaSearchResult, MediaTagsParams, MediaTagsResult, ReaderInfo, ReadersResult, - ScrapersResult, ScrapingStatusResponse, SettingsResult, SystemDefault, SystemsParams, - SystemsResult, TagInfo, TokensHistoryResult, TokensResult, UpdateSettingsParams, - VersionResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, + MediaMetaBatchParams, MediaMetaBatchResult, MediaMetaParams, MediaMetaResult, MediaResult, + MediaScrapeParams, MediaSearchParams, MediaSearchResult, MediaTagsParams, MediaTagsResult, + ReaderInfo, ReadersResult, ScrapersResult, ScrapingStatusResponse, SettingsResult, + SystemDefault, SystemsParams, SystemsResult, TagInfo, TokensHistoryResult, TokensResult, + UpdateSettingsParams, VersionResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, }; #[test] @@ -1971,6 +2007,52 @@ mod tests { assert!(!object.contains_key("path")); } + #[test] + fn media_meta_batch_preserves_order_and_ref_shapes() { + let params = MediaMetaBatchParams::try_new(vec![ + MediaMetaParams::for_media_id(42), + MediaMetaParams::for_media("SNES", "/roms/snes/x.sfc"), + ]) + .expect("valid batch"); + let json = serde_json::to_value(params).expect("serialise"); + let items = json["items"].as_array().expect("items"); + assert_eq!(items.len(), 2); + assert_eq!(items[0], serde_json::json!({"mediaId": 42})); + assert_eq!( + items[1], + serde_json::json!({"system": "SNES", "path": "/roms/snes/x.sfc"}) + ); + } + + #[test] + fn media_meta_batch_enforces_item_cap() { + let hundred = vec![MediaMetaParams::for_media_id(1); 100]; + assert!(MediaMetaBatchParams::try_new(hundred).is_ok()); + let hundred_one = vec![MediaMetaParams::for_media_id(1); 101]; + assert!(MediaMetaBatchParams::try_new(hundred_one).is_err()); + assert!(MediaMetaBatchParams::try_new(Vec::new()).is_err()); + } + + #[test] + fn media_meta_batch_result_parses_mixed_items() { + let json = r#"{"items":[ + {"media":{"path":"/a","title":{}}}, + {"error":"media not found"} + ]}"#; + let result: MediaMetaBatchResult = serde_json::from_str(json).expect("parse"); + assert_eq!(result.items.len(), 2); + assert_eq!( + result.items[0] + .media + .as_ref() + .map(|media| media.path.as_str()), + Some("/a") + ); + assert!(result.items[0].error.is_none()); + assert!(result.items[1].media.is_none()); + assert_eq!(result.items[1].error.as_deref(), Some("media not found")); + } + #[test] fn media_meta_result_parses_documented_payload() { // Mirrors the documented example from From 0b3eee482928ca41436ff1abfa9d58517006f987 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 17 Aug 2026 05:35:30 +0800 Subject: [PATCH 4/9] perf(mister): tune frontend responsiveness and scaling Remove cover-gated navigation, reduce UI-thread image and delegate work, and add measured startup/navigation diagnostics. Resolve digital framebuffer size from active MiSTer output so 1080p uses an integer-scaled 960x540 scene. Co-authored-by: Giancarlo Erra --- docs/architecture.md | 2 +- rust/frontend/src/lib.rs | 11 +- rust/frontend/src/media_image_cache.rs | 4 +- rust/frontend/src/media_meta_cache.rs | 47 +- rust/frontend/src/mister_runtime.rs | 267 +++++++- rust/frontend/src/models/app_status.rs | 4 +- rust/frontend/src/models/categories.rs | 29 +- rust/frontend/src/models/favorite_systems.rs | 4 +- rust/frontend/src/models/favorites.rs | 278 ++------ rust/frontend/src/models/games.rs | 489 ++++++------- rust/frontend/src/models/recents.rs | 16 + rust/frontend/src/models/settings.rs | 23 +- rust/frontend/src/models/systems.rs | 31 +- rust/zaparoo-core/src/client.rs | 3 + rust/zaparoo-core/src/endpoints/catalog.rs | 54 ++ src/app/main.cpp | 22 +- src/app/media_image_provider.cpp | 183 +++-- src/app/media_image_provider.h | 29 +- src/app/tinted_svg_image_provider.cpp | 1 + src/ui/app/Main.qml | 436 ++++++------ src/ui/app/MainLayout.qml | 104 ++- src/ui/components/BrowseDetailPane.qml | 8 +- src/ui/components/CMakeLists.txt | 1 - src/ui/components/CoreStatusPill.qml | 20 +- src/ui/components/HeaderBar.qml | 9 + src/ui/components/LetterJumpModal.qml | 14 +- src/ui/components/ListPickerModal.qml | 2 +- src/ui/components/PagedGrid.qml | 121 +++- src/ui/components/QrCodeModal.qml | 111 +-- src/ui/components/ScrollingCaption.qml | 11 +- src/ui/components/Tile.qml | 151 ++-- src/ui/components/TileLoader.qml | 8 + src/ui/components/TopStatusStrip.qml | 14 +- src/ui/screens/FavoriteSystemsScreen.qml | 25 +- src/ui/screens/FavoritesScreen.qml | 2 + src/ui/screens/GamesScreen.qml | 27 +- src/ui/screens/HubScreen.qml | 12 +- src/ui/screens/MediaListScreen.qml | 94 ++- src/ui/screens/RecentsScreen.qml | 3 + src/ui/screens/SettingsScreen.qml | 51 +- src/ui/screens/SystemsScreen.qml | 19 +- src/ui/theme/Resources.qml | 1 - src/ui/theme/Sizing.qml | 5 +- src/ui/translations/frontend_ar.ts | 662 +++++++++--------- src/ui/translations/frontend_de.ts | 662 +++++++++--------- src/ui/translations/frontend_el.ts | 662 +++++++++--------- src/ui/translations/frontend_en.ts | 684 +++++++++---------- src/ui/translations/frontend_es.ts | 662 +++++++++--------- src/ui/translations/frontend_eu.ts | 660 +++++++++--------- src/ui/translations/frontend_fr.ts | 660 +++++++++--------- src/ui/translations/frontend_he.ts | 662 +++++++++--------- src/ui/translations/frontend_hi.ts | 662 +++++++++--------- src/ui/translations/frontend_it.ts | 662 +++++++++--------- src/ui/translations/frontend_ja.ts | 662 +++++++++--------- src/ui/translations/frontend_ko.ts | 662 +++++++++--------- src/ui/translations/frontend_nl.ts | 662 +++++++++--------- src/ui/translations/frontend_ro.ts | 662 +++++++++--------- src/ui/translations/frontend_sk.ts | 662 +++++++++--------- src/ui/translations/frontend_uk.ts | 662 +++++++++--------- src/ui/translations/frontend_zh_CN.ts | 662 +++++++++--------- tests/ui/tst_letter_jump_modal.qml | 17 +- tests/ui/tst_list_picker_modal.qml | 7 + tests/ui/tst_navigation.qml | 166 ++++- tests/ui/tst_paged_grid.qml | 59 ++ tests/ui/tst_resources.qml | 152 +++++ tests/ui/tst_sizing.qml | 6 + 66 files changed, 7570 insertions(+), 6855 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 811b3c6e..92951340 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ src/app/main.cpp │ │ impl for QML singletons (sync seed + qt_thread watcher). │ │ │ ├── src/mister_runtime.rs - │ │ Pre-Qt setup on ARM32: vmode resolution switch, zaparoo.sh start. + │ │ Pre-Qt ARM32 setup: automatic framebuffer sizing, zaparoo.sh start. │ │ Compiled on all platforms; MiSTer-specific calls are gated by cfg. │ │ │ ├── src/models/ [Zaparoo.Browse QML module via cxx-qt 0.8] diff --git a/rust/frontend/src/lib.rs b/rust/frontend/src/lib.rs index 2b652654..6c926b0b 100644 --- a/rust/frontend/src/lib.rs +++ b/rust/frontend/src/lib.rs @@ -376,12 +376,10 @@ pub extern "C" fn zaparoo_rust_init(crt_native_path_forced: bool) -> c_int { // CRT path always renders to one of the native writer's mode // geometries (352x240 NTSC, 352x288 PAL, 720x480 480i), selected by - // the persisted video standard. User-configured [video] dimensions - // still apply to the normal MiSTer path, but `--crt` overrides them - // so startup `vmode`, the desktop preview canvas, and the writer's - // fb0 validation all agree. frontend.toml is the durable source for - // the standard and offsets (state.toml lives on tmpfs on MiSTer and - // mirrors it). + // the persisted video standard. Digital MiSTer ignores user-configured + // [video] dimensions and resolves an automatic framebuffer size below. + // frontend.toml remains the durable source for CRT standard and offsets + // (state.toml lives on tmpfs on MiSTer and mirrors it). if crt_native_path_forced { let standard = zaparoo_core::config::normalize_crt_video_standard( config.settings.crt_video_standard.as_deref().unwrap_or(""), @@ -396,6 +394,7 @@ pub extern "C" fn zaparoo_rust_init(crt_native_path_forced: bool) -> c_int { let _ = CRT_H_OFFSET.set(h_offset); let _ = CRT_V_OFFSET.set(v_offset); } + mister_runtime::resolve_video_size(&mut config, crt_native_path_forced); // Cache the language override so `zaparoo_rust_language_code` (called // from main.cpp before the QML engine loads) can return it without diff --git a/rust/frontend/src/media_image_cache.rs b/rust/frontend/src/media_image_cache.rs index 6815aecd..892a5498 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -887,8 +887,8 @@ impl MediaImageCache { } /// Drop queued-but-not-in-flight cover requests. Cached bytes, - /// negative memos, and the single request currently being fetched - /// stay untouched. Drained keys leave `pending` so final-page + /// negative memos, and requests currently being fetched stay untouched. + /// Drained keys leave `pending` so final-page /// prefetch after rapid navigation can re-enqueue them. pub fn clear_pending_requests(&self) { let drained = self.drain_queue(); diff --git a/rust/frontend/src/media_meta_cache.rs b/rust/frontend/src/media_meta_cache.rs index 6acd061e..8800a956 100644 --- a/rust/frontend/src/media_meta_cache.rs +++ b/rust/frontend/src/media_meta_cache.rs @@ -25,6 +25,7 @@ use std::collections::{HashMap, HashSet}; use std::mem::size_of; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use tracing::debug; use zaparoo_core::media_types::{ @@ -96,7 +97,7 @@ impl MediaMetaCache { let mut guard = self.state.lock().unwrap(); guard.clock += 1; let now = guard.clock; - match guard.map.get_mut(key) { + let result = match guard.map.get_mut(key) { Some(entry) => { entry.clock = now; match &entry.meta { @@ -105,7 +106,19 @@ impl MediaMetaCache { } } None => MetaLookup::Miss, - } + }; + let outcome = match &result { + MetaLookup::Hit(_) => "hit", + MetaLookup::Negative => "negative", + MetaLookup::Miss => "miss", + }; + debug!( + system_id = %key.system_id, + path = %key.path, + outcome, + "media_meta_cache: lookup" + ); + result } /// Insert a resolved fetch outcome. `Some` is a positive hit, `None` a @@ -127,12 +140,38 @@ impl MediaMetaCache { } global_handle().spawn(async move { let (keys, params): (Vec<_>, Vec<_>) = to_fetch.into_iter().unzip(); + let batch_size = keys.len(); + let started = Instant::now(); let result = global_store().client().media_meta_batch(params).await; let cache = global_media_meta_cache(); match result { - Ok(batch) => cache.finish_prefetch(keys, Some(batch)), + Ok(batch) => { + let hits = batch + .items + .iter() + .filter(|item| item.media.is_some()) + .count(); + let errors = batch + .items + .iter() + .filter(|item| item.error.is_some()) + .count(); + debug!( + batch_size, + hits, + errors, + duration_ms = started.elapsed().as_millis(), + "media_meta_cache: batch prefetch complete" + ); + cache.finish_prefetch(keys, Some(batch)); + } Err(error) => { - debug!(error = %error.message, "media_meta_cache: batch prefetch failed"); + debug!( + batch_size, + duration_ms = started.elapsed().as_millis(), + error = %error.message, + "media_meta_cache: batch prefetch failed" + ); cache.finish_prefetch(keys, None); } } diff --git a/rust/frontend/src/mister_runtime.rs b/rust/frontend/src/mister_runtime.rs index ca36437c..5f93e0aa 100644 --- a/rust/frontend/src/mister_runtime.rs +++ b/rust/frontend/src/mister_runtime.rs @@ -2,14 +2,29 @@ // Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. // SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 -/// Sets `QT_QPA_PLATFORM=linuxfb`, `QT_QUICK_BACKEND=software`, and the -/// configured linuxfb video mode before `QGuiApplication`. No-op on -/// non-MiSTer builds. -/// -/// The frontend owns `MiSTer` resolution startup so restart-applied -/// settings take effect on the very next process boot. Both the normal -/// and `--crt` paths keep linuxfb in `rgb32`, which is the mode the -/// frontend has been using in practice on `MiSTer`. +/// Resolve digital `MiSTer` render dimensions before Rust exports video size +/// to C++/QML. Main's launcher defers this process until HDMI mode selection is +/// complete, so a full-scale request reveals active output geometry instead of +/// whichever framebuffer size happened to be inherited during startup. +pub fn resolve_video_size(config: &mut zaparoo_core::config::Config, crt_native_path_forced: bool) { + #[cfg(zaparoo_runtime = "mister")] + if !crt_native_path_forced { + let inherited = current_framebuffer_size(); + let (width, height) = probe_automatic_render_size() + .or_else(|| inherited.map(|(width, height)| automatic_render_size(width, height))) + // Keep stale persisted resolution out of the fallback path if + // neither Main's scale command nor framebuffer sysfs is available. + .unwrap_or((1280, 720)); + config.video_width = width; + config.video_height = height; + } + #[cfg(not(zaparoo_runtime = "mister"))] + let _ = (config, crt_native_path_forced); +} + +/// Set `linuxfb` environment before `QGuiApplication`. Digital framebuffer +/// sizing was already applied while resolving video size; `--crt` remains +/// config-driven and unchanged. No-op off `MiSTer`. pub fn apply_pre_qt_setup(config: &zaparoo_core::config::Config, crt_native_path_forced: bool) { #[cfg(zaparoo_runtime = "mister")] { @@ -35,20 +50,92 @@ pub fn apply_pre_qt_setup(config: &zaparoo_core::config::Config, crt_native_path set_fb_mode_sysfs(config.video_width, config.video_height); } else { info!( - "applying linuxfb mode {}x{} rgb32", - config.video_width, config.video_height + render_width = config.video_width, + render_height = config.video_height, + "using automatic MiSTer linuxfb render size" ); - run_vmode_with_format(config.video_width, config.video_height, "rgb32"); } } #[cfg(not(zaparoo_runtime = "mister"))] let _ = (config, crt_native_path_forced); } +#[cfg(any(zaparoo_runtime = "mister", test))] +const FULL_SIZE_MAX_HEIGHT: u32 = 720; +#[cfg(zaparoo_runtime = "mister")] +const FB_MODE_PATH: &str = "/sys/module/MiSTer_fb/parameters/mode"; +#[cfg(zaparoo_runtime = "mister")] +const FB_VIRTUAL_SIZE_PATH: &str = "/sys/class/graphics/fb0/virtual_size"; + +/// Fallback for older Main builds that cannot apply scale-relative framebuffer +/// commands. Matching Main builds use `probe_automatic_render_size` instead. +#[cfg(any(zaparoo_runtime = "mister", test))] +fn automatic_render_size(width: u32, height: u32) -> (u32, u32) { + if height > FULL_SIZE_MAX_HEIGHT { + ((width / 2).max(1), (height / 2).max(1)) + } else { + (width, height) + } +} + +#[cfg(zaparoo_runtime = "mister")] +fn probe_automatic_render_size() -> Option<(u32, u32)> { + run_vmode_scale("f", "rgb32").ok()?; + let full_size = current_framebuffer_size()?; + if full_size.1 <= FULL_SIZE_MAX_HEIGHT { + return Some(full_size); + } + + // Let Main derive half scale from active HDMI timing. Unlike dividing an + // inherited framebuffer, this remains correct when the previous process + // left fb0 at an unrelated size. + run_vmode_scale("h", "rgb32").ok()?; + if let Some(scaled_size) = current_framebuffer_size() { + if scaled_size != full_size { + return Some(scaled_size); + } + } + + // Older vmode/Main pairs may accept only explicit geometry. Keep this as a + // compatibility fallback, then trust sysfs for what was actually applied. + let (width, height) = automatic_render_size(full_size.0, full_size.1); + run_vmode_with_format(width, height, "rgb32").ok()?; + current_framebuffer_size().or(Some(full_size)) +} + +#[cfg(zaparoo_runtime = "mister")] +fn current_framebuffer_size() -> Option<(u32, u32)> { + std::fs::read_to_string(FB_MODE_PATH) + .ok() + .and_then(|mode| parse_fb_mode(&mode)) + .or_else(|| { + std::fs::read_to_string(FB_VIRTUAL_SIZE_PATH) + .ok() + .and_then(|size| parse_virtual_size(&size)) + }) +} + +#[cfg(any(zaparoo_runtime = "mister", test))] +fn parse_fb_mode(mode: &str) -> Option<(u32, u32)> { + let mut fields = mode.split_whitespace(); + fields.next()?; + fields.next()?; + let width = fields.next()?.parse::().ok()?; + let height = fields.next()?.parse::().ok()?; + (width > 0 && height > 0).then_some((width, height)) +} + +#[cfg(any(zaparoo_runtime = "mister", test))] +fn parse_virtual_size(size: &str) -> Option<(u32, u32)> { + let (width, height) = size.trim().split_once(',')?; + let width = width.trim().parse::().ok()?; + let height = height.trim().parse::().ok()?; + (width > 0 && height > 0).then_some((width, height)) +} + #[cfg(zaparoo_runtime = "mister")] fn set_fb_mode_sysfs(width: u32, height: u32) { use tracing::{info, warn}; - const FB_MODE_PATH: &str = "/sys/module/MiSTer_fb/parameters/mode"; let stride = width * 4; let mode = format!("8888 1 {width} {height} {stride}"); match std::fs::read_to_string(FB_MODE_PATH) { @@ -66,38 +153,146 @@ fn set_fb_mode_sysfs(width: u32, height: u32) { } } +#[cfg(any(zaparoo_runtime = "mister", test))] +fn vmode_scale_command(scale: &str, pixel_format: &str) -> std::process::Command { + let mut command = std::process::Command::new("vmode"); + command.args([scale, pixel_format]); + command +} + +#[cfg(any(zaparoo_runtime = "mister", test))] +fn vmode_resolution_command(width: u32, height: u32, pixel_format: &str) -> std::process::Command { + let mut command = std::process::Command::new("vmode"); + command.args(["-r", &width.to_string(), &height.to_string(), pixel_format]); + command +} + #[cfg(zaparoo_runtime = "mister")] -fn run_vmode_with_format(width: u32, height: u32, pixel_format: &str) { - use tracing::warn; - let status = std::process::Command::new("vmode") - .args(["-r", &width.to_string(), &height.to_string(), pixel_format]) - .status(); - match status { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - warn!("vmode not found — display mode unchanged"); - } - Err(e) => warn!("vmode error: {e}"), - Ok(s) if !s.success() => { - warn!( - "vmode exited with {:?} — display mode may not have changed", - s.code() - ); - } - Ok(_) => {} - } +fn run_vmode_scale(scale: &str, pixel_format: &str) -> std::io::Result<()> { + run_vmode_command(vmode_scale_command(scale, pixel_format)) +} + +#[cfg(zaparoo_runtime = "mister")] +fn run_vmode_with_format(width: u32, height: u32, pixel_format: &str) -> std::io::Result<()> { + run_vmode_command(vmode_resolution_command(width, height, pixel_format)) +} + +#[cfg(zaparoo_runtime = "mister")] +fn run_vmode_command(mut command: std::process::Command) -> std::io::Result<()> { + use std::process::Stdio; + + // MiSTer's vmode script returns 1 both when res_count confirms a change and + // when its bounded wait expires. Geometry from sysfs is authoritative. + command.stdout(Stdio::null()).stderr(Stdio::null()); + command.status().map(|_| ()) +} + +#[cfg(any(zaparoo_runtime = "mister", test))] +fn core_service_start_command() -> std::process::Command { + let mut command = std::process::Command::new("/usr/bin/taskset"); + command.args([ + "-c", + "0-1", + "/media/fat/Scripts/zaparoo.sh", + "-service", + "start", + ]); + command } /// Fire-and-forget `zaparoo.sh -service start`. No-op on non-MiSTer builds. +/// Core must not inherit the frontend's CPU-0-only affinity: Go runtime worker +/// and audio threads created from that process would then remain pinned to the +/// same core. `taskset` gives the service wrapper and every descendant both +/// `MiSTer` CPUs while leaving frontend affinity unchanged. pub fn ensure_core_service_running() { #[cfg(zaparoo_runtime = "mister")] { use tracing::{info, warn}; - info!("spawning core service wrapper: zaparoo.sh -service start"); - if let Err(e) = std::process::Command::new("/media/fat/Scripts/zaparoo.sh") - .args(["-service", "start"]) - .spawn() - { - warn!("failed to start zaparoo.sh: {e}"); + info!("spawning core service wrapper with CPU affinity 0-1"); + if let Err(e) = core_service_start_command().spawn() { + warn!("failed to start zaparoo.sh with taskset: {e}"); } } } + +#[cfg(test)] +mod tests { + use super::{ + automatic_render_size, core_service_start_command, parse_fb_mode, parse_virtual_size, + vmode_resolution_command, vmode_scale_command, + }; + + #[test] + fn automatic_render_size_keeps_720_and_below_native() { + assert_eq!(automatic_render_size(1280, 720), (1280, 720)); + assert_eq!(automatic_render_size(640, 480), (640, 480)); + assert_eq!(automatic_render_size(352, 240), (352, 240)); + } + + #[test] + fn automatic_render_size_halves_above_720() { + assert_eq!(automatic_render_size(1920, 1080), (960, 540)); + assert_eq!(automatic_render_size(2560, 1440), (1280, 720)); + assert_eq!(automatic_render_size(1366, 768), (683, 384)); + } + + #[test] + fn parses_mister_fb_mode() { + assert_eq!(parse_fb_mode("8888 1 1920 1080 7680\n"), Some((1920, 1080))); + assert_eq!(parse_fb_mode("invalid"), None); + assert_eq!(parse_fb_mode("8888 1 0 720 0"), None); + } + + #[test] + fn parses_linux_fb_virtual_size() { + assert_eq!(parse_virtual_size("1280,720\n"), Some((1280, 720))); + assert_eq!(parse_virtual_size("1280x720"), None); + assert_eq!(parse_virtual_size("0,720"), None); + } + + #[test] + fn builds_scale_relative_vmode_command() { + let command = vmode_scale_command("h", "rgb32"); + assert_eq!(command.get_program(), "vmode"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + ["h", "rgb32"] + ); + } + + #[test] + fn builds_explicit_vmode_fallback_command() { + let command = vmode_resolution_command(960, 540, "rgb32"); + assert_eq!(command.get_program(), "vmode"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + ["-r", "960", "540", "rgb32"] + ); + } + + #[test] + fn core_service_starts_with_both_mister_cpus() { + let command = core_service_start_command(); + assert_eq!(command.get_program(), "/usr/bin/taskset"); + assert_eq!( + command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(), + [ + "-c", + "0-1", + "/media/fat/Scripts/zaparoo.sh", + "-service", + "start", + ] + ); + } +} diff --git a/rust/frontend/src/models/app_status.rs b/rust/frontend/src/models/app_status.rs index 7bdeb3c3..c678f1e3 100644 --- a/rust/frontend/src/models/app_status.rs +++ b/rust/frontend/src/models/app_status.rs @@ -214,8 +214,8 @@ fn bind_core_version(mut model: Pin<&mut ffi::AppStatus>) { let connected = { matches!(&*rx.borrow_and_update(), ConnectionState::Connected) }; if connected { // Flip `core_version_checked` whether or not the fetch - // succeeds: the first-run modal chain in Main.qml waits on - // it, so a failed `version` RPC must still resolve the gate. + // succeeds: Main's Core-version warning waits on it, so a + // failed `version` RPC must still resolve the gate. // On error we fail open (supported = true, empty version). let (version, supported) = match client.version().await { Ok(result) => { diff --git a/rust/frontend/src/models/categories.rs b/rust/frontend/src/models/categories.rs index 7484c124..b42d1dc0 100644 --- a/rust/frontend/src/models/categories.rs +++ b/rust/frontend/src/models/categories.rs @@ -2,7 +2,7 @@ // Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. // SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 -use crate::models::{with_hidden_browse_prefs_read, with_persist_read}; +use crate::models::{global_store, with_hidden_browse_prefs_read, with_persist_read}; use cxx_qt::CxxQtType; use cxx_qt_lib::{QByteArray, QHash, QHashPair_i32_QByteArray, QModelIndex, QString, QVariant}; use std::pin::Pin; @@ -38,17 +38,14 @@ pub struct CategoriesModelRust { /// from `count` (visible categories) and `raw_count` (all categories): /// Core's launchables surface as systems under the `Other` category /// even with no media-db index, so `count`/`raw_count` are non-zero on - /// a fresh device. `indexed_count` ignores launchables, so the first- - /// run scan prompt in `Main.qml` can tell "no games indexed yet" apart - /// from "only launchables present". + /// a fresh device. `indexed_count` ignores launchables, so background + /// first-run indexing can tell "no games indexed yet" apart from "only + /// launchables present". indexed_count: i32, - // Sticky-true flag: flips to true the first time the catalog - // resolves Ready, never resets. The first-run modal in - // `Main.qml` gates on `loaded && count === 0` so it only fires - // after we've seen an authoritative empty catalog — without - // this we'd misread the initial Default state (count=0, - // pre-fetch) as "no systems" and fire the modal on every cold - // launch before Core has answered. + // Sticky-true flag: flips to true the first time the catalog resolves + // Ready, never resets. Main's background first-run index gate waits for + // `loaded && indexed_count === 0`; otherwise the default pre-fetch zero + // state would start indexing before Core answered. loaded: bool, error_message: QString, } @@ -97,6 +94,12 @@ pub mod ffi { #[qinvokable] fn reproject(self: Pin<&mut CategoriesModel>); + /// Force the shared catalog endpoint to refetch. SystemsModel uses the + /// same resource, so one request refreshes both Hub categories and the + /// active Systems grid. + #[qinvokable] + fn refresh(self: Pin<&mut CategoriesModel>); + #[inherit] #[cxx_name = "beginResetModel"] fn begin_reset_model(self: Pin<&mut CategoriesModel>); @@ -295,6 +298,10 @@ impl ffi::CategoriesModel { fn reproject(self: Pin<&mut Self>) { reproject_inner(self); } + + fn refresh(self: Pin<&mut Self>) { + global_store().subscribe::(()).refetch(); + } } #[cfg(test)] diff --git a/rust/frontend/src/models/favorite_systems.rs b/rust/frontend/src/models/favorite_systems.rs index cbe3f403..845eac51 100644 --- a/rust/frontend/src/models/favorite_systems.rs +++ b/rust/frontend/src/models/favorite_systems.rs @@ -17,7 +17,7 @@ use zaparoo_core::endpoints::systems_favorites::SystemsFavoritesEndpoint; use zaparoo_core::media_types::SystemsResult; use zaparoo_core::remote_resource::ResourceStatus; -use crate::models::systems::SystemInfo; +use crate::models::systems::{sort_systems_by_display_name, SystemInfo}; const COVER_KEY_ROLE: i32 = 256 + 1; const NAME_ROLE: i32 = 256 + 2; @@ -182,7 +182,7 @@ fn rows_for_catalog( }) .collect() }); - rows.sort_by_key(|system| system.name.to_lowercase()); + sort_systems_by_display_name(&mut rows); rows } diff --git a/rust/frontend/src/models/favorites.rs b/rust/frontend/src/models/favorites.rs index a7db0f81..fe31cb29 100644 --- a/rust/frontend/src/models/favorites.rs +++ b/rust/frontend/src/models/favorites.rs @@ -25,11 +25,10 @@ use cxx_qt::{CxxQtType, Threading}; use cxx_qt_lib::{ QByteArray, QHash, QHashPair_i32_QByteArray, QList, QModelIndex, QString, QVariant, }; -use std::collections::HashSet; use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Instant; use tokio::sync::broadcast::error::RecvError; use tokio::task::JoinHandle; use tracing::{info, warn}; @@ -96,6 +95,8 @@ pub struct FavoritesModelRust { // action to this canonical Core system ID. current_system_id: QString, count: i32, + // Core's total across every cursor page. -1 means unknown. + total_items: i32, loading: bool, loading_more: bool, error_message: QString, @@ -139,22 +140,6 @@ pub struct FavoritesModelRust { // lazily on the first page apply so the model singleton owns // exactly one subscriber for the whole process lifetime. cover_subscription: Option>, - // Keys whose first-paint we're still waiting on. While non-empty - // we hold `loading = true` so the screen-flip overlay covers the - // gap between "page rendered with system logos" and "covers - // cached". Drained by `notify_cover_update` as each cover lands; - // force-cleared by the gate timer or a Pending/Errored transition. - pending_first_paint_keys: HashSet, - // Safety timer that force-releases the cover gate after a bounded - // delay, so a stalled bulk RPC can't park the user on `Loading…` - // forever. - cover_gate_timer: Option>, - // Bumped on every cover-gate arm and on every Pending/Errored - // disarm. The timer's queued closure compares against the current - // value and bails on a mismatch — necessary because aborting the - // JoinHandle doesn't cancel a callback already queued onto the Qt - // thread between sleep-completion and abort. - cover_gate_seq: Arc, nav_timing: Option, } @@ -166,6 +151,7 @@ impl Default for FavoritesModelRust { sort_mode: QString::default(), current_system_id: QString::default(), count: 0, + total_items: -1, loading: false, loading_more: false, error_message: QString::default(), @@ -191,9 +177,6 @@ impl Default for FavoritesModelRust { scope_seq: Arc::new(AtomicU64::new(0)), seq: Arc::new(AtomicU64::new(0)), cover_subscription: None, - pending_first_paint_keys: HashSet::new(), - cover_gate_timer: None, - cover_gate_seq: Arc::new(AtomicU64::new(0)), nav_timing: None, } } @@ -221,6 +204,7 @@ pub mod ffi { #[qml_element] #[qml_singleton] #[qproperty(i32, count)] + #[qproperty(i32, total_items)] #[qproperty(bool, loading)] #[qproperty(bool, loading_more)] #[qproperty(QString, error_message)] @@ -290,6 +274,9 @@ pub mod ffi { #[qinvokable] fn system_id_at(self: &FavoritesModel, index: i32) -> QString; + #[qinvokable] + fn system_name_at(self: &FavoritesModel, index: i32) -> QString; + #[qinvokable] fn peek_detail_at(self: Pin<&mut FavoritesModel>, index: i32); @@ -377,7 +364,15 @@ impl cxx_qt::Initialize for ffi::FavoritesModel { /// Snapshot of a single page that `apply_state` can write onto the /// model. Carried by value so the closure is `Send + 'static` for the /// `qt_thread` queue. -type PageSnapshot = (Vec, bool, Option); +type PageSnapshot = (Vec, bool, Option, i32); + +fn total_items_hint(total: i64) -> i32 { + if total < 0 { + -1 + } else { + i32::try_from(total).unwrap_or(i32::MAX) + } +} /// Project the resource status onto an `(Option, error)` /// tuple. `Idle`/`Loading` map to the same `(None, "")` shape so the @@ -389,6 +384,7 @@ fn project(status: &ResourceStatus) -> (Option, data.results.clone(), data.has_next_page(), data.next_cursor(), + total_items_hint(data.total), )), String::new(), ), @@ -402,7 +398,7 @@ fn apply_state( (data, err): (Option, String), ) { let apply_started = Instant::now(); - if let Some((entries, has_next_page, next_cursor)) = data { + if let Some((entries, has_next_page, next_cursor, total_items)) = data { if model.nav_timing.is_none() { model.as_mut().rust_mut().nav_timing = Some(NavTiming::new("cache")); } @@ -419,6 +415,9 @@ fn apply_state( clear_current_detail_state(model.as_mut()); let count = i32::try_from(entries.len()).unwrap_or(i32::MAX); let displays = compute_favorites_disambig_displays(&entries, model.show_original_filenames); + if model.total_items != total_items { + model.as_mut().set_total_items(total_items); + } model.as_mut().begin_reset_model(); { let mut rust = model.as_mut().rust_mut(); @@ -439,30 +438,16 @@ fn apply_state( if model.has_next_page != has_next_page { model.as_mut().set_has_next_page(has_next_page); } - // Hidden startup binding can pause cover requests so Hub paints without - // Favorites' off-screen cover gate. Screen entry resumes requests and - // refreshes visible cover roles. - if model.cover_requests_paused { - disarm_cover_gate(model.as_mut()); - if model.loading { - model.as_mut().set_loading(false); - } - finish_nav_timing(model.as_mut(), "covers-paused", 0); - } else { - // Decide whether to release `loading` immediately or hold it until - // covers are cached. `arm_cover_gate` flips loading off itself when - // the page has nothing to wait on; otherwise it leaves loading=true - // and arms the safety timer. - arm_cover_gate(model.as_mut()); + // Rows define destination readiness. Covers continue through the + // bounded image queue and reveal progressively; holding navigation on + // the slowest visible image made Favorites entry exceed two seconds. + if model.loading { + model.as_mut().set_loading(false); } + finish_nav_timing(model.as_mut(), "model-ready", 0); if model.loading_more { model.as_mut().set_loading_more(false); } - // Look-ahead prefetch: warm page 2 so the first scroll past the - // initial page doesn't surface a "Loading more…" cue. - if has_next_page && !model.cover_requests_paused { - model.as_mut().fetch_more(); - } } else if err.is_empty() { if model.nav_timing.is_none() { model.as_mut().rust_mut().nav_timing = Some(NavTiming::new("network")); @@ -475,9 +460,6 @@ fn apply_state( // is re-set when Ready lands. Bump `seq` and null `next_cursor` // so an in-flight `fetch_more` queued during the prior Ready // can't slip a stale append in before the next Ready arrives. - // Disarm the cover gate too: a stale timer firing during the - // next Ready would clear loading prematurely. - disarm_cover_gate(model.as_mut()); clear_current_detail_state(model.as_mut()); model.as_mut().rust_mut().seq.fetch_add(1, Ordering::SeqCst); model.as_mut().rust_mut().next_cursor = None; @@ -491,11 +473,6 @@ fn apply_state( model.as_mut().set_has_next_page(false); } } else { - // Same disarm as the Pending branch — an Errored transition - // doesn't reset entries, so a callback queued during the prior - // Ready could otherwise append rows that don't belong to the - // current chain. - disarm_cover_gate(model.as_mut()); clear_current_detail_state(model.as_mut()); model.as_mut().rust_mut().seq.fetch_add(1, Ordering::SeqCst); model.as_mut().rust_mut().next_cursor = None; @@ -877,6 +854,18 @@ impl ffi::FavoritesModel { QString::from(entry.system.id.as_str()) } + fn system_name_at(&self, index: i32) -> QString { + let Some(entry) = self.entry_at(index) else { + return QString::default(); + }; + let name = entry.system.name.trim(); + QString::from(if name.is_empty() { + entry.system.id.as_str() + } else { + name + }) + } + // Immediate, non-debounced sibling of `load_detail_at`. Called the moment // the focused row changes so the detail table reflects THIS row at once — // cached metadata (instant), a memoized blank, or a clean blank while a @@ -1552,13 +1541,8 @@ fn finish_nav_timing( /// Emit `dataChanged(coverKey)` for every row whose entry's /// `(systemId, mediaPath)` matches `key`. Cheap walk of the current -/// `entries` vec — favorites pages top out at a few hundred rows after -/// look-ahead, and the bridge runs only when the cover-cache fetch -/// driver delivers a result. -/// -/// Also drains `pending_first_paint_keys`: each cover landing during -/// the gate's hold ticks the set down, and emptying the set releases -/// the gate so the screen-flip overlay clears. +/// `entries` vec — loaded favorites remain cursor-paged, and the bridge runs +/// only when the cover-cache fetch driver delivers a result. fn notify_cover_update(mut model: Pin<&mut ffi::FavoritesModel>, key: &MediaKey) { let rows: Vec = model .entries @@ -1589,159 +1573,12 @@ fn notify_cover_update(mut model: Pin<&mut ffi::FavoritesModel>, key: &MediaKey) { sync_current_detail_image_key(model.as_mut()); } - // Tick the gate's pending set down. `remove` returns false if the - // key wasn't gated (broadcast events fire for every cache update, - // including miss-recovery enqueues from `cover_key_for`); we only - // try to release when a gated key was actually drained. - let was_pending = model - .as_mut() - .rust_mut() - .pending_first_paint_keys - .remove(key); - if was_pending && model.pending_first_paint_keys.is_empty() && model.loading { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } - // Bytes are cached, but QML's `MediaImageProvider` still has to - // decode them. The hidden cover pre-warmer in - // `FavoritesScreen.qml` dispatches all N requests at once and the - // provider's 4-worker pool decodes them in ~75–150 ms; without - // this settle window the gate flips `loading=false` before the - // last few decodes complete and the grid materialises with - // those tiles still showing the procedural fallback. Mirrors - // the same hand-off in `games.rs::notify_cover_update`. Same - // seq-ticket guard as the safety timer so a model reset - // cancels the pending release. - info!("favorites: cover gate bytes settled — entering decode-settle window"); - let seq = model.rust().cover_gate_seq.clone(); - let ticket = seq.fetch_add(1, Ordering::SeqCst) + 1; - let qt_thread = model.qt_thread(); - let handle = global_handle().spawn(async move { - tokio::time::sleep(Duration::from_millis(200)).await; - let _ = qt_thread.queue(move |mut model: Pin<&mut ffi::FavoritesModel>| { - if seq.load(Ordering::SeqCst) != ticket { - return; - } - model.as_mut().rust_mut().cover_gate_timer = None; - if model.loading { - info!("favorites: cover gate released after decode-settle window"); - model.as_mut().set_loading(false); - } - finish_nav_timing(model.as_mut(), "covers-ready", 0); - }); - }); - model.as_mut().rust_mut().cover_gate_timer = Some(handle); - } // Re-check the adjacent preload keys: a neighbor's bytes may have // just landed, upgrading its key from `icons/Loading` to // `media-image/...` so the hidden Image can start decoding. refresh_adjacent_cover_prefetch(model); } -/// Compute the set of media keys on the current page whose covers we -/// must wait on before releasing the cover gate. Rows without enough -/// info to key on, already-cached keys, and negatively-memoised keys -/// are all excluded. Pure helper so the gate's binning logic is unit- -/// testable without spinning up the global cache + tokio runtime. -fn compute_unresolved_keys( - entries: &[MediaItem], - is_cached: F, - is_negative: G, -) -> HashSet -where - F: Fn(&MediaKey) -> bool, - G: Fn(&MediaKey) -> bool, -{ - entries - .iter() - .filter_map(|entry| media_key_for(entry).map(MediaKey::with_current_cover_preference)) - .filter(|k| !is_cached(k) && !is_negative(k)) - .collect() -} - -/// Decide whether to hold `loading=true` until the page's covers are -/// cached, or release immediately. Called once per Ready `apply_state`. -/// -/// - If every search row's cover is already cached or negatively- -/// memoised, set loading=false right now — the screen-flip overlay -/// clears. -/// - Otherwise, store the unresolved set on the model, arm a 3 s -/// safety timer, and leave loading=true. `notify_cover_update` will -/// drain the set as covers land; whichever happens first (set -/// empties or timer fires) releases the gate. -fn arm_cover_gate(mut model: Pin<&mut ffi::FavoritesModel>) { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } - let cache = global_media_image_cache(); - let visible = &model.entries; - let cover_keys = visible - .iter() - .filter_map(|entry| media_key_for(entry).map(MediaKey::with_current_cover_preference)) - .collect::>(); - let cover_total = cover_keys.len(); - let cover_cache_hits = cover_keys.iter().filter(|k| cache.is_cached(k)).count(); - let unresolved = compute_unresolved_keys( - visible, - |k| cache.is_cached(k), - |k| cache.is_negative(k) || cache.is_soft_no_image(k), - ); - if let Some(timing) = model.as_mut().rust_mut().nav_timing.as_mut() { - timing.start_gate(cover_total, cover_cache_hits, unresolved.len()); - } - if unresolved.is_empty() { - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - if model.loading { - model.as_mut().set_loading(false); - } - finish_nav_timing(model.as_mut(), "covers-ready", 0); - return; - } - info!( - pending = unresolved.len(), - "favorites: arm cover gate (holding loading until covers cached)" - ); - model.as_mut().rust_mut().pending_first_paint_keys = unresolved; - let seq = model.rust().cover_gate_seq.clone(); - let ticket = seq.fetch_add(1, Ordering::SeqCst) + 1; - let qt_thread = model.qt_thread(); - let handle = global_handle().spawn(async move { - tokio::time::sleep(Duration::from_secs(3)).await; - let _ = qt_thread.queue(move |model| { - if seq.load(Ordering::SeqCst) != ticket { - return; - } - release_cover_gate_after_timeout(model); - }); - }); - model.as_mut().rust_mut().cover_gate_timer = Some(handle); -} - -/// Tear down any active cover gate. Used by Pending/Errored apply paths -/// to invalidate an in-flight timer's queued callback (via the seq -/// bump) before the next Ready installs a fresh one. -fn disarm_cover_gate(mut model: Pin<&mut ffi::FavoritesModel>) { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - model.rust().cover_gate_seq.fetch_add(1, Ordering::SeqCst); -} - -/// Force-release the cover gate from the safety timer. Called only via -/// the timer's queued callback after a seq-match check; the -/// notify-driven release path lives inline in `notify_cover_update`. -fn release_cover_gate_after_timeout(mut model: Pin<&mut ffi::FavoritesModel>) { - let pending = model.pending_first_paint_keys.len(); - info!(pending, "favorites: cover gate timed out, releasing"); - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - model.as_mut().rust_mut().cover_gate_timer = None; - if model.loading { - model.as_mut().set_loading(false); - } - finish_nav_timing(model.as_mut(), "timeout", pending); -} - /// Build the `text` payload sent to Core's `run` for a search entry. /// Runtime launches prefer exact paths to avoid title/ZapScript /// ambiguity; portable write/QR paths prefer Core's `ZapScript`. @@ -1889,6 +1726,14 @@ mod tests { } } + #[test] + fn total_items_hint_preserves_unknown_and_clamps_large_totals() { + assert_eq!(total_items_hint(-1), -1); + assert_eq!(total_items_hint(0), 0); + assert_eq!(total_items_hint(42), 42); + assert_eq!(total_items_hint(i64::MAX), i32::MAX); + } + #[test] fn random_favorite_uses_current_core_scope() { assert_eq!( @@ -1938,30 +1783,9 @@ mod tests { } #[test] - fn confirmed_no_cover_skips_key_and_first_paint_gate() { + fn confirmed_no_cover_skips_image_request() { let mut entry = favorite_entry(); entry.has_cover = false; assert!(media_key_for(&entry).is_none()); - assert!(compute_unresolved_keys(&[entry], |_| false, |_| false).is_empty()); - } - - #[test] - fn compute_unresolved_keys_excludes_soft_no_image() { - let soft_key = MediaKey::new("SNES", "/games/favorite.rom"); - let mut pending_entry = favorite_entry(); - pending_entry.path = "/games/pending.rom".to_string(); - let entries = vec![favorite_entry(), pending_entry]; - let unresolved = compute_unresolved_keys( - &entries, - |_| false, - |k| { - k.system_id.as_ref() == soft_key.system_id.as_ref() - && k.path.as_ref() == soft_key.path.as_ref() - }, - ); - let expected: HashSet = [MediaKey::new("SNES", "/games/pending.rom")] - .into_iter() - .collect(); - assert_eq!(unresolved, expected); } } diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 72ed9b8f..29b2ba2e 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -104,23 +104,10 @@ const CORE_SERVEABLE_IMAGE_TYPES: &[&str] = &[ // test harness sees this until it overrides explicitly. Server cap is // 1000; grid page sizes top out at ~30 so we stay well inside bounds. const DEFAULT_PAGE_SIZE: i32 = 15; -// `media.browse` `max_results` for cursor follow-ups. Held separate -// from `page_size` (which dictates grid layout, scroll-thumb sizing, -// and the initial-page cover gate) so wire chunks can be larger than a -// single visual page without changing the grid math. Sized for the -// MiSTer main-thread cost of `apply_append_page`: each chunk runs -// `transform_entries` and a `begin_insert_rows`/`end_insert_rows` -// pair on the Qt thread, which stalls input until it returns. 500 was Core's wire-cost optimum but -// produced a multi-second stall on ARM32 with the indicator showing -// the whole time; 100 keeps the per-chunk stall short enough to be -// invisible while still cutting an 805-entry Arcade to ~8 round-trips. -// Server caps `max_results` at 1000; 100 stays well inside that. The -// initial browse keeps the smaller `page_size` so the cover gate -// doesn't have to wait on a big first decode. Cover prefetch is no -// longer driven by metadata page size — `prefetch_around` warms only -// the visible and next pages, so a large `FETCH_MORE_CHUNK_SIZE` no -// longer floods the cover queue. -const FETCH_MORE_CHUNK_SIZE: i32 = 100; +// Ordinary cursor follow-ups fetch one current visual page (`page_size`). Large +// chunks are reserved for explicit rapid scrolling and jump-to-letter paths; +// growing a ten-row model by 100 rows for one shoulder press inflated delegate +// work and made folder Back require expensive large-to-small resets. const FETCH_MORE_RAPID_CHUNK_SIZE: i32 = 300; // Ceiling for a jump-to-letter fetch (Core's `max_results` cap). A position // jump must load every row up to the target before @@ -148,12 +135,6 @@ const COVER_PREFETCH_PREVIOUS_PAGES: i32 = 1; // at the front of the queue when the user moves. const COVER_PREFETCH_CURSOR_NEXT: i32 = 4; const COVER_PREFETCH_CURSOR_PREV: i32 = 2; -// Bound how long navigation waits for cold visible covers. After this, -// the page becomes interactive and any remaining covers pop in via the -// normal update path. Keeps cold pages from waiting on the slowest -// `media.image` request while preserving no-pop-in for warm/cache-hit pages. -const COVER_GATE_TIMEOUT_MS: u64 = 300; - // `apply_append_page` sub-batches the model insert into chunks of this // many rows so the Repeater's per-delegate `createObject` cost (the // dominant Qt-thread stall on MiSTer at ~7-8 ms per Tile) is spread @@ -236,6 +217,9 @@ pub struct GamesModelRust { detail_prefetch_row: Option, cover_key_roles_enabled: bool, cover_requests_paused: bool, + // Bumped when a same-sized browse result replaces rows in place instead of + // emitting modelReset. Main.qml uses this edge to restore saved selection. + rows_revision: i32, // When true, the `name` role and `name_at()` return the original filename // (without extension) instead of Core's cleaned display name. Bound from // QML to the `Show original filenames` setting; flipping it re-emits @@ -273,36 +257,13 @@ pub struct GamesModelRust { // `start_initial_browse` so the model singleton owns exactly one // subscriber for the whole process lifetime. cover_subscription: Option>, - // Keys whose first-paint we're still waiting on. While non-empty we - // hold `loading = true` so the screen-flip overlay covers the gap - // between "page rendered with glyphs" and "covers cached". Drained - // by `notify_cover_update` as each cover lands; force-cleared by - // the gate timer or a subsequent `start_initial_browse`. - pending_first_paint_keys: HashSet, - // Safety timer that force-releases the cover gate after a bounded - // delay, so a stalled bulk RPC can't park the user on `Loading…` - // forever. - cover_gate_timer: Option>, - // Bumped on every cover-gate arm and on every `start_initial_browse`. - // The timer's queued closure compares against the current value and - // bails on a mismatch — necessary because aborting the JoinHandle - // doesn't cancel a callback that was already queued onto the Qt - // thread between sleep-completion and abort. - cover_gate_seq: Arc, description_seq: Arc, // Tagging ticket for in-flight append sub-batches scheduled by // `apply_append_page`. Bumped on every `start_initial_browse` so a // deferred batch from a stale dataset detects that the model has // moved on and bails before splicing rows from the old chain onto - // the new one. Same race shape as `cover_gate_seq`, just for the - // sub-batch fan-out. + // the new one. append_seq: Arc, - // True when `apply_initial_page` wants to start the metadata - // look-ahead `fetch_more` after the visible page is interactive. - // Starting it before the cover gate releases can splice rows and - // create delegates during a screen transition, which is exactly - // the UI-thread stall the loading overlay is trying to hide. - pending_initial_lookahead: bool, // First visible row in the grid. Bound from QML to // `gamesGrid.currentPage * gamesGrid.pageSize` so the model knows // which entries are on screen and can warm the next page's covers @@ -372,6 +333,7 @@ impl Default for GamesModelRust { detail_prefetch_row: None, cover_key_roles_enabled: true, cover_requests_paused: false, + rows_revision: 0, show_original_filenames: false, detail_image_keys: Vec::new(), watcher: None, @@ -380,12 +342,8 @@ impl Default for GamesModelRust { is_seeded: false, card_write_seq: Arc::new(AtomicU64::new(0)), cover_subscription: None, - pending_first_paint_keys: HashSet::new(), - cover_gate_timer: None, - cover_gate_seq: Arc::new(AtomicU64::new(0)), description_seq: Arc::new(AtomicU64::new(0)), append_seq: Arc::new(AtomicU64::new(0)), - pending_initial_lookahead: false, visible_first_row: 0, cover_max_size: 0, detail_cover_max_size: 0, @@ -446,6 +404,7 @@ pub mod ffi { #[qproperty(QString, detail_prefetch_key_prev)] #[qproperty(bool, cover_key_roles_enabled)] #[qproperty(bool, cover_requests_paused)] + #[qproperty(i32, rows_revision)] #[qproperty(bool, show_original_filenames, READ, WRITE = set_show_original_filenames, NOTIFY)] #[qproperty(i32, visible_first_row)] #[qproperty(i32, cover_max_size, READ, WRITE = set_cover_max_size, NOTIFY)] @@ -476,6 +435,9 @@ pub mod ffi { #[qinvokable] fn fetch_more_rapid(self: Pin<&mut GamesModel>); + #[qinvokable] + fn fetch_more_restore(self: Pin<&mut GamesModel>); + #[qinvokable] fn fetch_more_jump(self: Pin<&mut GamesModel>, target_index: i32); @@ -757,13 +719,21 @@ impl ffi::GamesModel { } fn fetch_more(self: Pin<&mut Self>) { - self.fetch_more_with_limit(FETCH_MORE_CHUNK_SIZE, false); + let limit = self.page_size.max(1); + self.fetch_more_with_limit(limit, false); } fn fetch_more_rapid(self: Pin<&mut Self>) { self.fetch_more_with_limit(FETCH_MORE_RAPID_CHUNK_SIZE, false); } + fn fetch_more_restore(self: Pin<&mut Self>) { + // Restoration is hidden behind the loading gate. Use one bulk insert + // and pause covers for the request so the 300-row response cannot be + // split into per-frame sub-batches that each restart cover prefetch. + self.fetch_more_with_limit(FETCH_MORE_RAPID_CHUNK_SIZE, true); + } + fn fetch_more_jump(self: Pin<&mut Self>, target_index: i32) { // Size the fetch to the gap remaining to the jump target instead of // always pulling the server max. `target_index` is the absolute grid @@ -1488,7 +1458,7 @@ impl ffi::GamesModel { } /// Issue a fresh `media.browse` for `(path, systems)`. Bumps `seq`, - /// aborts the prior watcher, clears entries via `beginResetModel`, + /// aborts the prior watcher, retains hidden rows for efficient replacement, /// subscribes to `MediaBrowseEndpoint`, and spawns a watcher whose /// queued callbacks bail unless the ticket still matches. /// @@ -1511,6 +1481,11 @@ impl ffi::GamesModel { "games: start_initial_browse", ); self.as_mut().rust_mut().nav_timing = Some(NavTiming::new("network")); + // Navigation owns the fetch budget. Drop queued covers from the + // previous folder before model/cache work begins so stale image RPCs + // cannot delay a cached parent restore or the destination's first frame. + // Already in-flight requests finish normally. + global_media_image_cache().clear_pending_requests(); self.as_mut().ensure_cover_subscription(); self.as_mut().set_current_path(QString::from(path.as_str())); self.as_mut().set_loading(true); @@ -1542,10 +1517,6 @@ impl ffi::GamesModel { // reset and preserve appended pages + selection. self.as_mut().rust_mut().is_seeded = false; self.as_mut().rust_mut().next_cursor = None; - // Drop any held initial-look-ahead gate from the prior browse — - // its append (if it lands at all) will be ticket-rejected and - // can't re-arm the gate for this new target. - self.as_mut().rust_mut().pending_initial_lookahead = false; // Invalidate any in-flight sub-batch posts from the prior // browse: each posted closure compares against the snapshotted // ticket and bails if the model has moved on. Otherwise a @@ -1555,15 +1526,11 @@ impl ffi::GamesModel { .rust_mut() .append_seq .fetch_add(1, Ordering::SeqCst); - if !self.entries.is_empty() { - self.as_mut().begin_reset_model(); - self.as_mut().rust_mut().entries.clear(); - self.as_mut().rust_mut().disambig_displays.clear(); - self.as_mut().rust_mut().count = 0; - self.as_mut().end_reset_model(); - self.as_mut().count_changed(); - } - // Total-files counter resets too — the previous path's + // Keep prior rows mounted while Loading hides the grid. Clearing here + // would tear down delegates now, then rebuild them when destination + // rows arrive. Retaining them enables same-sized results to update in + // place and avoids two model resets for every folder navigation. + // Total-files counter resets — the previous path's // denominator would be misleading until the new fetch lands. self.as_mut().set_total_files(0); // Total-dirs counter is the other denominator term; reset it for @@ -1573,12 +1540,6 @@ impl ffi::GamesModel { if let Some(handle) = self.as_mut().rust_mut().watcher.take() { handle.abort(); } - // Tear down any cover gate left from the prior path. See - // `reset_cover_gate` for the rationale; without this teardown - // a stale timer callback could fire after the new path's - // `set_loading(true)` and prematurely release its gate. - reset_cover_gate(self.as_mut()); - let seq = self.rust().seq.clone(); let ticket = seq.fetch_add(1, Ordering::SeqCst) + 1; @@ -2667,10 +2628,6 @@ fn mark_nav_request_done(mut model: Pin<&mut ffi::GamesModel>) { /// `entries` vec — pages top out at a few hundred rows after look- /// ahead, and the bridge runs only when the cover-cache fetch driver /// delivers a result. -/// -/// Also drains `pending_first_paint_keys`: each cover landing during -/// the gate's hold ticks the set down, and emptying the set releases -/// the gate so the screen-flip overlay clears. fn notify_cover_update(mut model: Pin<&mut ffi::GamesModel>, key: &MediaKey) { let rows: Vec = model .entries @@ -2722,26 +2679,6 @@ fn notify_cover_update(mut model: Pin<&mut ffi::GamesModel>, key: &MediaKey) { let detail_keys = ordered_detail_image_keys(cover_key, type_keys, resolved.as_deref()); set_detail_image_keys(model.as_mut(), detail_keys); } - // Tick the gate's pending set down. `remove` returns false if the - // key wasn't gated (broadcast events fire for every cache update, - // including miss-recovery enqueues from `cover_key_for`); we only - // try to release when a gated key was actually drained. - let was_pending = model - .as_mut() - .rust_mut() - .pending_first_paint_keys - .remove(key); - if was_pending && model.pending_first_paint_keys.is_empty() && model.loading { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } - if model.loading { - info!("games: cover gate released after visible covers cached"); - model.as_mut().set_loading(false); - finish_nav_timing(model.as_mut(), "covers-ready", 0); - maybe_start_initial_lookahead(model.as_mut()); - } - } // Re-check the adjacent preload keys: a neighbor's bytes may have // just landed, which can flip its key from `icons/Loading` to // `media-image/...` and trigger the hidden Image's decode while the @@ -2749,11 +2686,8 @@ fn notify_cover_update(mut model: Pin<&mut ffi::GamesModel>, key: &MediaKey) { refresh_adjacent_cover_prefetch(model); } -/// Compute the set of media keys on the current page whose covers we -/// must wait on before releasing the cover gate. Folders, unattributed -/// entries, already-cached keys, and negatively-memoised keys are all -/// excluded. Pure helper so the gate's binning logic is unit-testable -/// without spinning up the global cache + tokio runtime. +/// Compute unresolved visible-cover keys for navigation telemetry. Folders, +/// unattributed entries, cached keys, and negatively memoized keys are excluded. fn compute_unresolved_keys( entries: &[BrowseEntry], is_cached: F, @@ -2774,45 +2708,12 @@ where .collect() } -/// Abort any in-flight cover-gate timer, drop the waiting-keys set, -/// and bump `cover_gate_seq` so a callback that already queued onto -/// the Qt thread before the abort took effect sees a stale ticket and -/// bails. Used on every browse status edge that doesn't go on to call -/// `arm_cover_gate` itself (Pending, Errored, and the -/// `start_initial_browse` reset). -fn reset_cover_gate(mut model: Pin<&mut ffi::GamesModel>) { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - model.rust().cover_gate_seq.fetch_add(1, Ordering::SeqCst); -} - -/// Decide whether to hold `loading=true` until the page's covers are -/// cached, or release immediately. Called once per `apply_initial_page`. -/// -/// - If every media entry is already cached or negatively-memoised -/// (folder-only page, or revisit), set loading=false right now — -/// there's nothing to wait on, the screen-flip overlay clears. -/// - Otherwise, store the unresolved set on the model, arm a short -/// safety timer, and leave loading=true. `notify_cover_update` will -/// drain the set as covers land; whichever happens first (set empties -/// or timer fires) releases the gate. -/// -/// The timeout is the fall-through: if visible cover fetches are cold, -/// the user sees `Loading…` only briefly before the existing "list with -/// placeholders → covers pop in" behavior resumes. -fn arm_cover_gate(mut model: Pin<&mut ffi::GamesModel>) { - if let Some(handle) = model.as_mut().rust_mut().cover_gate_timer.take() { - handle.abort(); - } +/// Release the model as soon as rows are installed. Cover statistics remain in +/// navigation telemetry, but raster readiness is deliberately not a navigation +/// gate: QML paints one stable card/text frame, then enables cover sources from +/// the following frame callback. +fn release_model_before_covers(mut model: Pin<&mut ffi::GamesModel>) { let cache = global_media_image_cache(); - // Scope the waiting set to the visible page only, not all loaded - // entries. The prefetcher queues only ~3 pages' worth; computing - // over all entries means the set can never drain on a large folder - // (e.g. 411 PSX dirs) and the gate always rides the full timeout. - // Using the visible page (page_size rows starting at visible_first_row) - // lets the set drain as soon as the on-screen covers land. let page_size = model.page_size.max(1) as usize; let first = model.rust().visible_first_row.max(0) as usize; let window_end = (first + page_size).min(model.entries.len()); @@ -2832,73 +2733,9 @@ fn arm_cover_gate(mut model: Pin<&mut ffi::GamesModel>) { if let Some(timing) = model.as_mut().rust_mut().nav_timing.as_mut() { timing.start_gate(cover_total, cover_cache_hits, unresolved.len()); } - if unresolved.is_empty() { - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - if model.loading { - model.as_mut().set_loading(false); - finish_nav_timing(model.as_mut(), "covers-ready", 0); - maybe_start_initial_lookahead(model.as_mut()); - } - return; - } - info!( - pending = unresolved.len(), - "games: arm cover gate (holding loading until covers cached)" - ); - model.as_mut().rust_mut().pending_first_paint_keys = unresolved; - arm_cover_gate_timeout(model); -} - -fn arm_cover_gate_timeout(mut model: Pin<&mut ffi::GamesModel>) { - let seq = model.rust().cover_gate_seq.clone(); - let ticket = seq.fetch_add(1, Ordering::SeqCst) + 1; - let qt_thread = model.qt_thread(); - let handle = global_handle().spawn(async move { - tokio::time::sleep(Duration::from_millis(COVER_GATE_TIMEOUT_MS)).await; - let _ = qt_thread.queue(move |mut model: Pin<&mut ffi::GamesModel>| { - if seq.load(Ordering::SeqCst) != ticket { - return; - } - if model.loading && !model.pending_first_paint_keys.is_empty() { - release_cover_gate_after_timeout(model); - } else { - model.as_mut().rust_mut().cover_gate_timer = None; - } - }); - }); - model.as_mut().rust_mut().cover_gate_timer = Some(handle); -} - -fn maybe_start_initial_lookahead(mut model: Pin<&mut ffi::GamesModel>) { - if !model.pending_initial_lookahead - || model.loading - || model.loading_more - || !model.has_next_page - { - return; - } - model.as_mut().rust_mut().pending_initial_lookahead = false; - model.as_mut().fetch_more(); -} - -/// Clear stale look-ahead state after the background prefetch lands or fails. -fn release_initial_lookahead_gate(mut model: Pin<&mut ffi::GamesModel>) { - model.as_mut().rust_mut().pending_initial_lookahead = false; -} - -/// Force-release the cover gate from the safety timer. Called only via -/// the timer's queued callback after a seq-match check; the -/// notify-driven release path lives inline in `notify_cover_update`. -fn release_cover_gate_after_timeout(mut model: Pin<&mut ffi::GamesModel>) { - let pending = model.pending_first_paint_keys.len(); - info!(pending, "games: cover gate timed out, releasing"); - model.as_mut().rust_mut().pending_first_paint_keys.clear(); - model.as_mut().rust_mut().cover_gate_timer = None; - // Safety timer is the hard upper bound for visible covers. if model.loading { model.as_mut().set_loading(false); - finish_nav_timing(model.as_mut(), "timeout", pending); - maybe_start_initial_lookahead(model.as_mut()); + finish_nav_timing(model.as_mut(), "model-ready", 0); } } @@ -3063,12 +2900,6 @@ fn apply_status(mut model: Pin<&mut ffi::GamesModel>, status: ResourceStatus { mark_nav_source(model.as_mut(), "network"); - // A new browse round started (or a Ready→Pending refetch - // is in flight). Abort any cover gate left from the - // previous Ready so its safety-timer callback can't race - // with the loading=true we're about to set and clear it - // mid-load. - reset_cover_gate(model.as_mut()); if !model.loading { model.as_mut().set_loading(true); } @@ -3164,10 +2995,13 @@ fn apply_status(mut model: Pin<&mut ffi::GamesModel>, status: ResourceStatus { warn!("media.browse errored: {message}"); - // Errored takes us out of Ready without going through - // `arm_cover_gate`, so any timer left armed by the prior - // Ready needs to be torn down explicitly. - reset_cover_gate(model.as_mut()); + // Prior-target rows stay mounted only while a replacement is + // pending. On failure, remove them so the error state cannot paint + // stale content beneath ScreenStateOverlay. Seeded refetch errors + // retain current rows because they still represent active scope. + if !model.is_seeded { + clear_visible_entries(model.as_mut()); + } let qstr = QString::from(message.as_str()); if model.error_message != qstr { model.as_mut().set_error_message(qstr); @@ -3256,6 +3090,95 @@ fn apply_seeded_refetch(mut model: Pin<&mut ffi::GamesModel>, result: &MediaBrow finish_nav_timing(model.as_mut(), "already-seeded", 0); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InitialRowReplacement { + InPlace, + TruncateInPlace, + Reset, +} + +fn initial_row_replacement(current_count: i32, next_count: i32) -> InitialRowReplacement { + if current_count > 0 && current_count == next_count { + InitialRowReplacement::InPlace + } else if next_count > 0 && current_count > next_count { + InitialRowReplacement::TruncateInPlace + } else { + InitialRowReplacement::Reset + } +} + +fn clear_visible_entries(mut model: Pin<&mut ffi::GamesModel>) { + if model.entries.is_empty() { + return; + } + model.as_mut().begin_reset_model(); + model.as_mut().rust_mut().entries.clear(); + model.as_mut().rust_mut().disambig_displays.clear(); + model.as_mut().rust_mut().count = 0; + model.as_mut().end_reset_model(); + model.as_mut().count_changed(); +} + +fn replace_initial_rows( + mut model: Pin<&mut ffi::GamesModel>, + entries: Vec, + displays: Vec, +) -> &'static str { + let count = i32::try_from(entries.len()).unwrap_or(i32::MAX); + let replacement = initial_row_replacement(model.count, count); + if replacement == InitialRowReplacement::TruncateInPlace { + let old_count = model.count; + // Remove only surplus tail rows. Prefix delegates survive, so paged + // folder Back does not destroy and reconstruct destination's first page. + // QML has already reset selection/pending targets on loading=true. + let parent = QModelIndex::default(); + model + .as_mut() + .begin_remove_rows(&parent, count, old_count - 1); + { + let mut rust = model.as_mut().rust_mut(); + rust.entries = entries; + rust.disambig_displays = displays; + rust.count = count; + } + model.as_mut().end_remove_rows(); + model.as_mut().count_changed(); + } else if replacement == InitialRowReplacement::InPlace { + model.as_mut().rust_mut().entries = entries; + model.as_mut().rust_mut().disambig_displays = displays; + } else { + model.as_mut().begin_reset_model(); + model.as_mut().rust_mut().entries = entries; + model.as_mut().rust_mut().disambig_displays = displays; + model.as_mut().rust_mut().count = count; + model.as_mut().end_reset_model(); + model.as_mut().count_changed(); + return "reset"; + } + + let parent = QModelIndex::default(); + let top_left = model.as_mut().index(0, 0, &parent); + let bottom_right = model.as_mut().index(count - 1, 0, &parent); + // Repeater delegates consume only these roles. An empty roles list means + // "all roles" and needlessly re-evaluates path, launch, description, + // file-count, and entry-type consumers during the first-frame update. + let mut roles = QList::::default(); + roles.append(NAME_ROLE); + roles.append(COVER_KEY_ROLE); + roles.append(FAVORITE_ROLE); + roles.append(HIDDEN_ROLE); + roles.append(DISAMBIGUATING_TAGS_ROLE); + model + .as_mut() + .data_changed(&top_left, &bottom_right, &roles); + let revision = model.rows_revision.wrapping_add(1); + model.as_mut().set_rows_revision(revision); + if replacement == InitialRowReplacement::TruncateInPlace { + return "truncate-in-place"; + } + "in-place" +} + fn apply_initial_page(mut model: Pin<&mut ffi::GamesModel>, result: MediaBrowseResult) { let apply_started = Instant::now(); if model.is_seeded { @@ -3277,29 +3200,13 @@ fn apply_initial_page(mut model: Pin<&mut ffi::GamesModel>, result: MediaBrowseR ); let reset_started = Instant::now(); let displays = compute_disambig_displays(&entries, model.show_original_filenames); - model.as_mut().begin_reset_model(); - model.as_mut().rust_mut().entries = entries; - model.as_mut().rust_mut().disambig_displays = displays; - model.as_mut().rust_mut().count = count; model.as_mut().rust_mut().next_cursor = next_cursor; - // Property setters run BEFORE `end_reset_model` so Main.qml's - // `onModelReset` handler observes the post-load state. In - // particular, the deep-page restore branch reads `has_next_page` - // to decide whether to chase the saved entry across pages; if - // we set it after `end_reset_model`, that handler sees the - // stale `false` left by `start_initial_browse` and abandons the - // restore (currentIndex snaps to 0). The order in - // `apply_append_page` is the opposite (set has_next_page AFTER - // the last insert) for a different reason: that path needs - // PagedGrid's pending-target watchdog to read a fresh itemCount - // when the flag flips false on the terminal chunk. A fresh - // model reset has no pending-target watchdog to mislead, so - // setting the flag early here is safe. + // Pagination properties must be current before either modelReset or + // rowsRevision asks Main.qml to restore a saved deep-page selection. model.as_mut().set_total_files(total); model.as_mut().set_total_dirs(total_dirs); model.as_mut().set_has_next_page(has_next_page); - model.as_mut().end_reset_model(); - model.as_mut().count_changed(); + let replacement_mode = replace_initial_rows(model.as_mut(), entries, displays); let reset_ms = reset_started.elapsed().as_millis(); // Seed the cover queue from the visible row outwards instead of // bulk-enqueuing every entry. The grid resets to row 0 on a fresh @@ -3309,11 +3216,9 @@ fn apply_initial_page(mut model: Pin<&mut ffi::GamesModel>, result: MediaBrowseR let prefetch_started = Instant::now(); model.as_mut().prefetch_around(0); let prefetch_ms = prefetch_started.elapsed().as_millis(); - // Seed the detail key for row 0 so the stale key from the previous - // folder cannot paint the instant the cover gate releases. The gate - // waits on the same warm cover key, so by the time `loading` flips - // false the cache-update handler has already promoted the key to its - // `media-image/...` form (or left it as the new folder/file chip). + // Seed detail key for row 0 so stale key from previous folder cannot + // paint when the row model appears. Cache-update handler later promotes + // it to `media-image/...` form (or leaves new folder/file chip). // The `is_seeded` early-return above skips this for invalidation // refetches; they share the same browse target and row 0 is already // correct. @@ -3349,35 +3254,27 @@ fn apply_initial_page(mut model: Pin<&mut ffi::GamesModel>, result: MediaBrowseR apply_ms = apply_started.elapsed().as_millis(), "games: apply_initial_page timing", ); - // Metadata look-ahead starts only after the visible page is - // interactive. Otherwise the follow-up append can create delegates - // during the transition and extend the perceived navigation stall. - let will_lookahead = has_next_page && !model.loading_more; - if will_lookahead { - model.as_mut().rust_mut().pending_initial_lookahead = true; - } - // Decide whether to release `loading` immediately or hold it until - // visible-page covers are cached. Background metadata look-ahead - // does not participate in this gate. - let gate_arm_started = Instant::now(); - arm_cover_gate(model.as_mut()); - let gate_arm_ms = gate_arm_started.elapsed().as_millis(); + // Release `loading` as soon as rows are installed. Cover statistics are + // recorded, but QML reveals those images after the first model frame. + let reveal_release_started = Instant::now(); + release_model_before_covers(model.as_mut()); + let reveal_release_ms = reveal_release_started.elapsed().as_millis(); debug!( count, + replacement_mode, transform_ms, reset_ms, prefetch_ms, - gate_arm_ms, + reveal_release_ms, total_ms = apply_started.elapsed().as_millis(), "games: apply_initial_page detail timing", ); if !model.error_message.is_empty() { model.as_mut().set_error_message(QString::default()); } - // If the visible page released synchronously (all covers cached or - // no media covers), start look-ahead now. Otherwise the release path - // calls `maybe_start_initial_lookahead` after loading flips false. - maybe_start_initial_lookahead(model.as_mut()); + // Keep only initial visible page mounted. PagedGrid requests follow-up + // rows on demand; automatic 100-row look-ahead made parent/child model + // shapes differ and forced expensive delegate resets on every Back. // Mark seeded last so any early-return from this function leaves // the flag in its previous state. Subsequent Ready transitions // on the same browse target now skip the reset above. @@ -3545,7 +3442,6 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); return; } let mut batches = chunk_for_subbatching(entries, APPEND_SUB_BATCH_SIZE); @@ -3556,7 +3452,6 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); return; } // First batch runs synchronously inside the existing @@ -3578,20 +3473,16 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); return; } // Remaining batches: post one frame apart on the Qt - // thread, with the LAST one carrying the finaliser - // (has_next_page, total_files, look-ahead gate release). + // thread, with the LAST one carrying pagination finalization. // total_dirs is not touched here: Core computes it once on // page 1 and carries the same value forward, so it never // changes across appended pages. // - // No auto-prefetch here. apply_initial_page pre-warms - // chunk 2 once; subsequent chunks are driven by the - // grid's onLoadMoreRequested as the user scrolls. - // Chaining a fetch_more here turned the look-ahead into a + // Chunks are driven by the grid's onLoadMoreRequested as the user + // scrolls. Chaining a fetch_more here turned demand loading into a // self-driving cascade that downloaded every page // back-to-back, tripping Core's WebSocket rate limit on // huge folders (Arcade). @@ -3616,13 +3507,6 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - // Clear the look-ahead gate now that the - // first follow-up chunk has fully landed. - // If the cover gate already drained - // (covers all cached or decode-settle - // fired while we held it open) we're the - // one releasing loading. - release_initial_lookahead_gate(model.as_mut()); } }); } @@ -3643,12 +3527,6 @@ fn apply_append_page( if bulk { model.as_mut().set_cover_requests_paused(false); } - // Even on a failed first prefetch, we have to release the - // cover gate's hold or the user is stuck on Loading… - // forever. The visible page is already in place from - // `apply_initial_page`; the missing tail just won't be - // there until the user retries. - release_initial_lookahead_gate(model.as_mut()); } } } @@ -3666,12 +3544,14 @@ mod tests { child_launch_text_from_browse_result, chunk_for_subbatching, compute_unresolved_keys, cover_key_for_with, cover_placeholder_for, decide_initial, dedup_roots_drop_ancestors, detail_image_keys_from_meta, detail_tags_from_tags, display_name, display_title_for_entry, - entry_system_id, favorites_tags, games_random_launch_text, is_media_capable_entry, - is_strict_ancestor_path, jump_fetch_limit, media_capable_directory_browse_params, - media_key_for, meta_cache_key_for_entry, meta_params_for_entry, ordered_detail_image_keys, - position_of_game_path, prefetch_around_plan, prefetch_cursor_window_plan, project_status, - result_total_dirs, run_text_for_entry, seeded_refetch_pagination_state, - singleton_directory_needs_launch_resolution, transform_entries, InitialAction, Projection, + entry_system_id, favorites_tags, games_random_launch_text, initial_row_replacement, + is_media_capable_entry, is_strict_ancestor_path, jump_fetch_limit, + media_capable_directory_browse_params, media_key_for, meta_cache_key_for_entry, + meta_params_for_entry, ordered_detail_image_keys, position_of_game_path, + prefetch_around_plan, prefetch_cursor_window_plan, project_status, result_total_dirs, + run_text_for_entry, seeded_refetch_pagination_state, + singleton_directory_needs_launch_resolution, transform_entries, InitialAction, + InitialRowReplacement, Projection, }; use super::{FETCH_MORE_RAPID_CHUNK_SIZE, JUMP_FETCH_CHUNK_SIZE}; use crate::media_image_cache::{MediaImageCache, MediaKey}; @@ -3848,6 +3728,38 @@ mod tests { assert_eq!(result_total_dirs(&result), 2); } + #[test] + fn same_sized_nonempty_pages_replace_rows_in_place() { + assert_eq!( + initial_row_replacement(10, 10), + InitialRowReplacement::InPlace + ); + assert_eq!( + initial_row_replacement(3, 3), + InitialRowReplacement::InPlace + ); + } + + #[test] + fn larger_models_remove_only_surplus_tail_rows() { + assert_eq!( + initial_row_replacement(20, 10), + InitialRowReplacement::TruncateInPlace + ); + assert_eq!( + initial_row_replacement(219, 10), + InitialRowReplacement::TruncateInPlace + ); + } + + #[test] + fn empty_or_growing_pages_require_reset() { + assert_eq!(initial_row_replacement(0, 0), InitialRowReplacement::Reset); + assert_eq!(initial_row_replacement(0, 10), InitialRowReplacement::Reset); + assert_eq!(initial_row_replacement(10, 0), InitialRowReplacement::Reset); + assert_eq!(initial_row_replacement(3, 10), InitialRowReplacement::Reset); + } + #[test] fn seeded_refetch_after_append_keeps_pagination_cursor() { let (next_cursor, has_next_page) = seeded_refetch_pagination_state( @@ -4537,9 +4449,7 @@ mod tests { #[test] fn compute_unresolved_keys_excludes_no_cover_entries() { // Core sends has_cover=false for entries with no image property row. - // These entries will never resolve to cached bytes, so they must - // not be included in the gate set — otherwise the gate would always - // ride the safety timer on systems like Arcade. + // Exclude entries that can never resolve from unresolved telemetry. let mut no_cover = media("nocovergame", "/p/nocovergame", "Arcade"); no_cover.has_cover = false; let entries = vec![no_cover, media("coveredgame", "/p/coveredgame", "NES")]; @@ -4552,9 +4462,8 @@ mod tests { #[test] fn compute_unresolved_keys_all_no_cover_returns_empty() { - // A page where Core confirmed no entry has a cover (e.g. Arcade - // with no scraped artwork) must result in an empty unresolved set - // so the gate releases immediately rather than timing out. + // A page where Core confirmed no entry has a cover has no unresolved + // visible-cover work. let mut a = media("a", "/p/a", "Arcade"); a.has_cover = false; let mut b = media("b", "/p/b", "Arcade"); diff --git a/rust/frontend/src/models/recents.rs b/rust/frontend/src/models/recents.rs index 72ad6efa..eb5618e8 100644 --- a/rust/frontend/src/models/recents.rs +++ b/rust/frontend/src/models/recents.rs @@ -259,6 +259,9 @@ pub mod ffi { #[qinvokable] fn system_id_at(self: &RecentsModel, index: i32) -> QString; + #[qinvokable] + fn system_name_at(self: &RecentsModel, index: i32) -> QString; + #[qinvokable] fn peek_detail_at(self: Pin<&mut RecentsModel>, index: i32); @@ -818,6 +821,19 @@ impl ffi::RecentsModel { QString::from(self.entries[index as usize].system_id.as_str()) } + fn system_name_at(&self, index: i32) -> QString { + if index < 0 || index >= self.count { + return QString::default(); + } + let entry = &self.entries[index as usize]; + let name = entry.system_name.trim(); + QString::from(if name.is_empty() { + entry.system_id.as_str() + } else { + name + }) + } + // Immediate, non-debounced sibling of `load_detail_at`. Called the moment // the focused row changes so the detail table reflects THIS row at once — // cached metadata (instant), a memoized blank, or a clean blank while a diff --git a/rust/frontend/src/models/settings.rs b/rust/frontend/src/models/settings.rs index 3a1bbdc6..43fb2817 100644 --- a/rust/frontend/src/models/settings.rs +++ b/rust/frontend/src/models/settings.rs @@ -10,12 +10,10 @@ // Field design: // * `is_mister` — CONSTANT. Drives whether MiSTer-only fields render // in the form. -// * `available_resolutions` — CONSTANT. Empty off MiSTer; on MiSTer, -// the curated picker list. Order matters: it's the cycle order in -// the UI's left/right cycler. -// * `current_resolution` — READ + NOTIFY, persisted. Empty means "use -// `[mister.video_*]` defaults from frontend.toml". The Settings -// screen renders that empty value as `qsTr("Default")`. +// * `available_resolutions` / `current_resolution` — retained as a +// persisted compatibility surface for future platforms. Resolution is +// no longer user-selectable, and digital MiSTer startup ignores it in +// favor of automatic framebuffer sizing. // * `available_languages` — CONSTANT. Curated language tags plus the // `auto` sentinel. The runtime translator is still startup-only, so // this setting applies on the next launch. @@ -66,7 +64,7 @@ // Frontend-owned durable settings are mirrored into both `state.toml` // and `frontend.toml`. `state.toml` keeps the in-process snapshot // coherent; `frontend.toml` is the durable copy that survives MiSTer's -// `/tmp` lifecycle and is what startup `vmode` / translator install +// `/tmp` lifecycle and is what startup services / translator install // read on the next process launch. Button layout only changes the QML // resource path used by help-bar icons, browse layout selects the game // browsing presentation, mouse support drives the QML cursor/input blocker, @@ -84,14 +82,9 @@ use zaparoo_core::persist::{self, SettingsState}; use zaparoo_core::platform_paths::config_file_path; use zaparoo_core::runtime; -/// Curated `MiSTer` resolution choices. Order is the left/right cycle -/// order in the form. Keep the list short — every entry is a literal -/// the user can crash a CRT scaler with if it doesn't suit their -/// monitor — and ASCII-only so the QML side never needs to translate -/// the strings (they're not user-facing labels, they're keys). The -/// empty leading entry is the "use `frontend.toml` defaults" sentinel; -/// the form renders it as `qsTr("Default")` so users can cycle back -/// to no-override after picking a custom value. +/// Legacy `MiSTer` resolution values retained for config/state compatibility +/// and possible future platform use. Digital `MiSTer` startup does not consume +/// this selection while automatic framebuffer sizing is active. const MISTER_RESOLUTIONS: &[&str] = &[ "", "1280x720", diff --git a/rust/frontend/src/models/systems.rs b/rust/frontend/src/models/systems.rs index b94ad5df..1212fb37 100644 --- a/rust/frontend/src/models/systems.rs +++ b/rust/frontend/src/models/systems.rs @@ -290,6 +290,16 @@ fn position_of_system_id(systems: &[SystemInfo], needle: &str) -> i32 { /// `region` drives both the localized display name (via `system_names`) and /// the logo artwork stem (via `system_logos`). Resolve it once before calling /// this function and pass it in so the caller controls the snapshot. +pub(crate) fn sort_systems_by_display_name(systems: &mut [SystemInfo]) { + systems.sort_by_cached_key(|system| { + ( + system.name.to_lowercase(), + system.name.clone(), + system.id.clone(), + ) + }); +} + fn rows_for_category( catalog: Option<&CatalogData>, cat: &str, @@ -298,7 +308,8 @@ fn rows_for_category( region: Region, ) -> Vec { catalog.map_or_else(Vec::new, |c| { - c.systems_by_category(cat) + let mut rows = c + .systems_by_category(cat) .into_iter() .filter_map(|s| { let is_hidden = hidden_ids.contains(&s.id); @@ -327,7 +338,9 @@ fn rows_for_category( zap_script: s.zap_script, }) }) - .collect() + .collect::>(); + sort_systems_by_display_name(&mut rows); + rows }) } @@ -939,6 +952,20 @@ mod tests { assert!(!rows[0].hidden); } + #[test] + fn rows_for_category_sorts_by_resolved_display_name() { + let catalog = catalog_with(vec![ + sys("InternalFirst", "Zulu", "Consoles"), + sys("InternalLast", "alpha", "Consoles"), + ]); + let rows = rows_for_category(Some(&catalog), "Consoles", &[], false, Region::Us); + assert_eq!( + rows.iter().map(|row| row.name.as_str()).collect::>(), + ["alpha", "Zulu"] + ); + assert_eq!(rows[0].id, "InternalLast"); + } + #[test] fn rows_for_category_preserves_system_metadata() { let mut nes = sys("nes", "Nintendo Entertainment System", "Consoles"); diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index cc59d479..287c1e3f 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -482,6 +482,7 @@ impl Client { } debug!( method, + request_id = %id, duration_ms = started.elapsed().as_millis(), error = "not connected", "rpc round trip", @@ -499,6 +500,7 @@ impl Client { let payload_bytes = serde_json::to_vec(&val).map_or(0, |bytes| bytes.len()); debug!( method, + request_id = %id, duration_ms = started.elapsed().as_millis(), payload_bytes, "rpc round trip", @@ -508,6 +510,7 @@ impl Client { Err(e) => { debug!( method, + request_id = %id, duration_ms = started.elapsed().as_millis(), error = %e.message, "rpc round trip", diff --git a/rust/zaparoo-core/src/endpoints/catalog.rs b/rust/zaparoo-core/src/endpoints/catalog.rs index fddf2842..0a7438c0 100644 --- a/rust/zaparoo-core/src/endpoints/catalog.rs +++ b/rust/zaparoo-core/src/endpoints/catalog.rs @@ -53,6 +53,15 @@ impl Endpoint for CatalogEndpoint { /// systems list. Pulled out of `fetch` so tests can drive a deterministic /// fixture without standing up a `Client`. fn shape_catalog(mut systems: Vec) -> CatalogData { + // Core's systems response includes supported launcher systems even when + // their indexed media count is zero. They are useful to API clients, but + // are not browse destinations. Keep only systems with indexed media. + // Launch-only virtual systems are the exception: Core only returns + // available launchables for the default `all = false` request, and their + // ZapScript is the content they launch instead of indexed media. + systems.retain(|system| { + !system.zap_script.trim().is_empty() || system.media_count.is_some_and(|count| count > 0) + }); systems.sort_by_key(|a| a.name.to_lowercase()); let categories = derive_categories(&systems); info!( @@ -93,6 +102,7 @@ mod tests { id: id.into(), name: name.into(), category: category.into(), + media_count: Some(1), ..SystemInfo::default() } } @@ -128,6 +138,50 @@ mod tests { assert_eq!(derive_categories(&systems), vec!["Consoles", "Other"]); } + #[test] + fn shape_catalog_excludes_empty_and_unknown_counts_but_keeps_launchables() { + let systems = vec![ + SystemInfo { + id: "indexed".into(), + name: "Indexed".into(), + category: "Consoles".into(), + media_count: Some(3), + ..SystemInfo::default() + }, + SystemInfo { + id: "empty".into(), + name: "Empty".into(), + category: "Computers".into(), + media_count: Some(0), + ..SystemInfo::default() + }, + SystemInfo { + id: "old-core".into(), + name: "Unknown".into(), + category: "Handhelds".into(), + media_count: None, + ..SystemInfo::default() + }, + SystemInfo { + id: "virtual".into(), + name: "Virtual".into(), + category: "Utilities".into(), + media_count: Some(0), + zap_script: "zaparoo://launch/virtual".into(), + ..SystemInfo::default() + }, + ]; + + let catalog = shape_catalog(systems); + let ids = catalog + .systems + .iter() + .map(|system| system.id.as_str()) + .collect::>(); + assert_eq!(ids, vec!["indexed", "virtual"]); + assert_eq!(catalog.categories, vec!["Consoles", "Utilities"]); + } + #[test] fn shape_catalog_snapshot_matches_fixture() { let systems = vec![ diff --git a/src/app/main.cpp b/src/app/main.cpp index f59292c4..28cf9060 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -349,22 +349,24 @@ int main(int argc, char* argv[]) // NOLINT QGuiApplication::setFont(defaultFont); } startupTrace("cpp:font registration complete"); - if (crtNativePathEnabled) + bool useUnsmoothedText = crtNativePathEnabled; +#ifdef ZAPAROO_EMBEDDED_BUILD + // MiSTer's progressive framebuffer is now either 1280x720 or 960x540. + // On a 1080p output the latter is presented at an exact 2x scale, which + // doubles Noto Sans's grayscale antialias fringe and makes otherwise + // aligned text look soft. Rasterize monochrome, fully hinted glyphs at + // source resolution so integer output scaling preserves hard edges. + useUnsmoothedText = true; +#endif + if (useUnsmoothedText) { QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering); - qInfo("CRT native path: using native text rendering"); - // Desktop CRT preview: FreeType on X11/Wayland defaults to subpixel - // RGB antialiasing ("ClearType"), which paints faint coloured - // fringes either side of every glyph. MiSTer's linuxfb FreeType - // does not enable subpixel AA, so the same scene reads pixel- - // perfect there but blurry in the desktop preview. The bitmap - // pixel font (MxPlus HP 100LX 6x8) is also designed to never be - // smoothed. Set NoAntialias on the application default font so - // every Text item that doesn't override styleStrategy inherits it. QFont defaultFont = QGuiApplication::font(); defaultFont.setStyleStrategy(QFont::NoAntialias); defaultFont.setHintingPreference(QFont::PreferFullHinting); QGuiApplication::setFont(defaultFont); + qInfo(crtNativePathEnabled ? "CRT native path: using unsmoothed native text" + : "Embedded progressive path: using unsmoothed native text"); } QQuickStyle::setStyle("Basic"); diff --git a/src/app/media_image_provider.cpp b/src/app/media_image_provider.cpp index 98a3b43e..2abef33f 100644 --- a/src/app/media_image_provider.cpp +++ b/src/app/media_image_provider.cpp @@ -72,9 +72,10 @@ QImage scaleForRequestedSize(const QImage& image, const QSize& requestedSize) } // namespace MediaImageResponse::MediaImageResponse(QString id, QSize requestedSize, QMutex* cacheMutex, - QCache* decodedCache) + QCache* decodedCache, + std::array* decodeMutexes) : m_id(std::move(id)), m_requestedSize(requestedSize), m_cacheMutex(cacheMutex), - m_decodedCache(decodedCache) + m_decodedCache(decodedCache), m_decodeMutexes(decodeMutexes) { // QThreadPool would `delete` the runnable after `run()` returns, // but `QQuickAsyncImageProvider` expects the response to live until @@ -133,6 +134,97 @@ QQuickTextureFactory* MediaImageResponse::textureFactory() const return QQuickTextureFactory::textureFactoryForImage(m_image); } +MediaImageResponse::RawImageResult MediaImageResponse::loadRawImage(qint64 queueWaitUs, + int inflight) +{ + const QByteArray idUtf8 = m_id.toUtf8(); + const QString rawCacheKey = m_id + QStringLiteral(":raw"); + RawImageResult result; + QImage& image = result.image; + + // Serialize only requests that hash to the same stripe, then re-check raw + // cache under that lock. Width-only, height-only, and natural-size QML + // requests for one source therefore share a single WebP decode. + const auto stripe = static_cast(qHash(m_id)) % m_decodeMutexes->size(); + QMutexLocker decodeLocker(&m_decodeMutexes->at(stripe)); + { + QMutexLocker cacheLocker(m_cacheMutex); + if (const QImage* cached = m_decodedCache->object(rawCacheKey)) + { + result.cacheHit = true; + result.image = *cached; + return result; + } + } + + QByteArray bytes; + QElapsedTimer fetchTimer; + fetchTimer.start(); + zaparoo_media_image_bytes_for(idUtf8.constData(), static_cast(idUtf8.size()), + &appendBytesCallback, &bytes); + result.fetchUs = fetchTimer.nsecsElapsed() / 1000; + qDebug("media-image provider: id=%s bytes=%lld fetch_us=%lld", idUtf8.constData(), + static_cast(bytes.size()), static_cast(result.fetchUs)); + if (bytes.isEmpty()) + { + const qint64 totalUs = m_lifetime.nsecsElapsed() / 1000; + qDebug("media-image provider: 0 bytes for id=%s (no cover or empty payload) " + "queue_wait_us=%lld fetch_us=%lld total_us=%lld inflight=%d", + idUtf8.constData(), static_cast(queueWaitUs), + static_cast(result.fetchUs), static_cast(totalUs), inflight); + return {}; + } + + QElapsedTimer decodeTimer; + decodeTimer.start(); + const bool decodeOk = image.loadFromData(bytes); + result.decodeUs = decodeTimer.nsecsElapsed() / 1000; + if (!decodeOk) + { + // First 8 bytes identify common formats and help separate malformed + // payloads from missing handlers in static Qt builds. + const qsizetype prefixLen = bytes.size() < 8 ? bytes.size() : 8; + QString prefixHex; + prefixHex.reserve(prefixLen * 3); + for (qsizetype i = 0; i < prefixLen; ++i) + { + const auto byteVal = static_cast(bytes.at(i)); + if (i > 0) + { + prefixHex.append(QLatin1Char(' ')); + } + prefixHex.append(QStringLiteral("%1").arg(byteVal, 2, 16, QLatin1Char('0'))); + } + QStringList formatNames; + const QList supportedFormats = QImageReader::supportedImageFormats(); + formatNames.reserve(supportedFormats.size()); + for (const QByteArray& fmt : supportedFormats) + { + formatNames << QString::fromLatin1(fmt); + } + const qint64 totalUs = m_lifetime.nsecsElapsed() / 1000; + qWarning("media-image provider: QImage::loadFromData failed for id=%s bytes=%lld " + "prefix=[%s] supportedFormats=[%s] queue_wait_us=%lld fetch_us=%lld " + "decode_us=%lld total_us=%lld inflight=%d", + idUtf8.constData(), static_cast(bytes.size()), + qUtf8Printable(prefixHex), qUtf8Printable(formatNames.join(QStringLiteral(", "))), + static_cast(queueWaitUs), static_cast(result.fetchUs), + static_cast(result.decodeUs), static_cast(totalUs), + inflight); + return {}; + } + + QMutexLocker cacheLocker(m_cacheMutex); + if (!m_decodedCache->contains(rawCacheKey)) + { + const auto cost = static_cast(image.sizeInBytes()); + // QCache owns inserted pointers. + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + m_decodedCache->insert(rawCacheKey, new QImage(image), cost); + } + return result; +} + void MediaImageResponse::run() { // queue_wait: time this response sat in the pool between enqueue @@ -184,88 +276,33 @@ void MediaImageResponse::run() s_decoderNiced = true; } - // QtQuick strips the `image://media-image/` prefix before calling - // the provider, so `m_id` is the raw encoded key (base64url-no-pad). const QByteArray idUtf8 = m_id.toUtf8(); - QByteArray bytes; - QElapsedTimer fetchTimer; - fetchTimer.start(); - zaparoo_media_image_bytes_for(idUtf8.constData(), static_cast(idUtf8.size()), - &appendBytesCallback, &bytes); - const qint64 fetchUs = fetchTimer.nsecsElapsed() / 1000; - qDebug("media-image provider: id=%s bytes=%lld fetch_us=%lld", idUtf8.constData(), - static_cast(bytes.size()), static_cast(fetchUs)); - if (bytes.isEmpty()) - { - const qint64 totalUs = m_lifetime.nsecsElapsed() / 1000; - qDebug("media-image provider: 0 bytes for id=%s (no cover or empty payload) " - "queue_wait_us=%lld fetch_us=%lld total_us=%lld inflight=%d", - idUtf8.constData(), static_cast(queueWaitUs), - static_cast(fetchUs), static_cast(totalUs), inflight); - emit finished(); - return; - } - QElapsedTimer decodeTimer; - decodeTimer.start(); - QImage image; - const bool decodeOk = image.loadFromData(bytes); - const qint64 decodeUs = decodeTimer.nsecsElapsed() / 1000; - if (!decodeOk) + const RawImageResult raw = loadRawImage(queueWaitUs, inflight); + if (raw.image.isNull()) { - // First 8 bytes pin down the format: PNG = 89 50 4E 47 0D 0A 1A 0A, - // JPEG = FF D8 FF, WebP starts "RIFF....WEBP". Pairing the magic - // bytes with the registered format list tells us whether Core sent - // the wrong payload type or whether Qt simply has no handler for - // this format (the static MiSTer Qt build can ship without PNG). - const qsizetype prefixLen = bytes.size() < 8 ? bytes.size() : 8; - QString prefixHex; - prefixHex.reserve(prefixLen * 3); - for (qsizetype i = 0; i < prefixLen; ++i) - { - const auto byteVal = static_cast(bytes.at(i)); - if (i > 0) - { - prefixHex.append(QLatin1Char(' ')); - } - prefixHex.append(QStringLiteral("%1").arg(byteVal, 2, 16, QLatin1Char('0'))); - } - QStringList formatNames; - const QList supportedFormats = QImageReader::supportedImageFormats(); - formatNames.reserve(supportedFormats.size()); - for (const QByteArray& fmt : supportedFormats) - { - formatNames << QString::fromLatin1(fmt); - } - const qint64 totalUs = m_lifetime.nsecsElapsed() / 1000; - qWarning( - "media-image provider: QImage::loadFromData failed for id=%s bytes=%lld prefix=[%s] " - "supportedFormats=[%s] queue_wait_us=%lld fetch_us=%lld decode_us=%lld total_us=%lld " - "inflight=%d", - idUtf8.constData(), static_cast(bytes.size()), qUtf8Printable(prefixHex), - qUtf8Printable(formatNames.join(QStringLiteral(", "))), - static_cast(queueWaitUs), static_cast(fetchUs), - static_cast(decodeUs), static_cast(totalUs), inflight); emit finished(); return; } + QElapsedTimer scaleTimer; scaleTimer.start(); - m_image = scaleForRequestedSize(image, m_requestedSize); + m_image = scaleForRequestedSize(raw.image, m_requestedSize); // Build the texture factory here so the GUI thread doesn't pay // the allocation cost during paint. `textureFactoryForImage` is // documented as safe to call on any thread; the resulting factory // wraps `m_image` and is consumed once by QtQuick after `finished()`. m_factory.reset(QQuickTextureFactory::textureFactoryForImage(m_image)); const qint64 scaleUs = scaleTimer.nsecsElapsed() / 1000; - // Store the decoded+scaled image so a re-invocation at the same tier skips - // the decode. Cost is tracked in bytes so maxCost caps total memory use. + // Store only genuine scaled variants. When requested output equals raw + // size, raw entry already provides same bitmap and charging both keys would + // halve effective cache capacity. + if (m_image.size() != raw.image.size()) { QMutexLocker locker(m_cacheMutex); if (!m_decodedCache->contains(cacheKey)) { const auto cost = static_cast(m_image.sizeInBytes()); - // QCache::insert takes ownership of the raw pointer; this is the - // documented API and the owning-memory diagnostic is expected here. + // QCache::insert takes ownership of the raw pointer. // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) m_decodedCache->insert(cacheKey, new QImage(m_image), cost); } @@ -280,11 +317,12 @@ void MediaImageResponse::run() // request tier matches what Core sent, i.e. no resample. const qint64 totalUs = m_lifetime.nsecsElapsed() / 1000; qDebug("media-image decode: id=%s req=%dx%d src=%dx%d out=%dx%d queue_wait_us=%lld " - "fetch_us=%lld decode_us=%lld scale_us=%lld total_us=%lld inflight=%d cached=0", - idUtf8.constData(), m_requestedSize.width(), m_requestedSize.height(), image.width(), - image.height(), m_image.width(), m_image.height(), static_cast(queueWaitUs), - static_cast(fetchUs), static_cast(decodeUs), - static_cast(scaleUs), static_cast(totalUs), inflight); + "fetch_us=%lld decode_us=%lld scale_us=%lld total_us=%lld inflight=%d cached=%d", + idUtf8.constData(), m_requestedSize.width(), m_requestedSize.height(), raw.image.width(), + raw.image.height(), m_image.width(), m_image.height(), + static_cast(queueWaitUs), static_cast(raw.fetchUs), + static_cast(raw.decodeUs), static_cast(scaleUs), + static_cast(totalUs), inflight, raw.cacheHit ? 1 : 0); emit finished(); } @@ -314,7 +352,8 @@ MediaImageProvider::~MediaImageProvider() QQuickImageResponse* MediaImageProvider::requestImageResponse(const QString& id, const QSize& requestedSize) { - auto* response = new MediaImageResponse(id, requestedSize, &m_cacheMutex, &m_decodedCache); + auto* response = + new MediaImageResponse(id, requestedSize, &m_cacheMutex, &m_decodedCache, &m_decodeMutexes); m_pool.start(response); return response; } diff --git a/src/app/media_image_provider.h b/src/app/media_image_provider.h index c4db6a02..32657975 100644 --- a/src/app/media_image_provider.h +++ b/src/app/media_image_provider.h @@ -32,19 +32,29 @@ #include #include #include +#include #include class MediaImageResponse : public QQuickImageResponse, public QRunnable { public: MediaImageResponse(QString id, QSize requestedSize, QMutex* cacheMutex, - QCache* decodedCache); + QCache* decodedCache, + std::array* decodeMutexes); ~MediaImageResponse() override = default; [[nodiscard]] QQuickTextureFactory* textureFactory() const override; void run() override; private: + struct RawImageResult + { + QImage image; + qint64 fetchUs = 0; + qint64 decodeUs = 0; + bool cacheHit = false; + }; + [[nodiscard]] RawImageResult loadRawImage(qint64 queueWaitUs, int inflight); QString m_id; QSize m_requestedSize; QImage m_image; @@ -57,6 +67,10 @@ class MediaImageResponse : public QQuickImageResponse, public QRunnable mutable std::unique_ptr m_factory; QMutex* m_cacheMutex; QCache* m_decodedCache; + // Striped per-ID decode locks. Requests for one cover at width-only, + // height-only, and natural size share one raw decode while unrelated + // covers still use separate workers. + std::array* m_decodeMutexes; // Started when the response is constructed (i.e. enqueued onto the // pool). Read at run() start it yields queue_wait (time spent waiting // for a free worker); read before each finished() it yields the total @@ -85,13 +99,12 @@ class MediaImageProvider : public QQuickAsyncImageProvider // beyond that, context-switch cost outweighs parallel decode on // the software-rendered build. QThreadPool m_pool; - // Process-memory cache for decoded covers. The WebP→QImage decode - // is the dominant cost (measured ~424 ms mean, p90 775 ms on MiSTer), - // and PagedGrid tears tiles down on rapid scroll so the same cover - // re-decodes on revisit (~3.76× per cover in a browse session). The - // decoded image is deterministic per (id, requestedSize), so caching - // it lets a re-invocation skip the FFI fetch and decode entirely. - // Cost is tracked in bytes; maxCost caps the footprint on MiSTer. + // Process-memory cache for raw decoded images plus scaled variants. Raw + // entries are keyed only by media ID, allowing width-only, height-only, + // and natural-size QML consumers to share expensive WebP decoding. Scaled + // variants keep their requested-size key. One shared byte cap accounts for + // both forms. QMutex m_cacheMutex; QCache m_decodedCache; + std::array m_decodeMutexes; }; diff --git a/src/app/tinted_svg_image_provider.cpp b/src/app/tinted_svg_image_provider.cpp index 1e52a5da..7cbff306 100644 --- a/src/app/tinted_svg_image_provider.cpp +++ b/src/app/tinted_svg_image_provider.cpp @@ -234,6 +234,7 @@ void TintedSvgImageResponse::run() if (!QFile::exists(fullResourcePath)) { m_error = QStringLiteral("missing tinted-svg resource"); + qWarning("tinted-svg provider: missing path=%s", qUtf8Printable(resourcePath)); emit finished(); return; } diff --git a/src/ui/app/Main.qml b/src/ui/app/Main.qml index 626cf1e0..0460c191 100644 --- a/src/ui/app/Main.qml +++ b/src/ui/app/Main.qml @@ -42,7 +42,6 @@ MainLayout { // Sentinel id for the favorites "default order" row; maps to an empty // sort mode. Must never be "" — see openFavoritesSortMenu. readonly property string _favoritesSortDefault: "default" - readonly property string modalFirstRunIndex: "first_run_index" readonly property string modalLogUpload: "log_upload" readonly property string modalQuitConfirm: "quit_confirm" readonly property string modalListPicker: "list_picker" @@ -50,16 +49,15 @@ MainLayout { readonly property string modalSettingNeedsRestart: "restart_confirm" readonly property string modalCrtCalibration: "crt_calibration" - // One-shot session flag: the first-run modal is shown at most - // once per frontend process, even if the WS link drops and the - // mediadb-empty condition would otherwise be satisfied again. - property bool _firstRunIndexShown: false - // One-shot guard for the Core-version warning, same lifetime as - // _firstRunIndexShown: show it at most once per process even if the + // One-shot session flag: an authoritative empty catalog starts one + // background index at most once per frontend process. Browsing remains + // available while newly discovered systems arrive through catalog polls. + property bool _firstRunIndexStarted: false + // One-shot guard for the Core-version warning, same process lifetime: + // show it at most once even if the // link drops and reconnects to the same old Core. property bool _coreVersionWarningShown: false property string _pendingLanguageSelection: "" - property string _pendingResolutionSelection: "" property string _pendingCrtStandardSelection: "" // Staged CRT-mode toggle awaiting the restart-confirm modal: // "" (none), "on", or "off". Confirming writes the 1-byte enable @@ -79,6 +77,15 @@ MainLayout { property string contextMenuMode: "main" property string contextMenuOwner: "" property int contextMenuIndex: -1 + + // One in-flight screen-transition sample. Input dispatch starts it, the + // router records when destination becomes active, and frameSwapped closes + // it after Qt presents destination's first frame. + property double _transitionInputStartedAt: 0 + property double _transitionRouteAt: 0 + property string _transitionAction: "" + property string _transitionFromScreen: "" + property string _transitionToScreen: "" readonly property bool activeCardWritePending: root.cardWriteOwner === "systems" ? Browse.SystemsModel.card_write_pending : root.cardWriteOwner === "games" ? Browse.GamesModel.card_write_pending : root.cardWriteOwner === "favorites" ? Browse.FavoritesModel.card_write_pending : false readonly property string activeCardWriteError: root.cardWriteOwner === "systems" ? Browse.SystemsModel.card_write_error : root.cardWriteOwner === "games" ? Browse.GamesModel.card_write_error : root.cardWriteOwner === "favorites" ? Browse.FavoritesModel.card_write_error : "" @@ -264,8 +271,6 @@ MainLayout { root.coreVersionModalRequested = true; else if (modal === root.modalRandomFailed) root.randomFailedModalRequested = true; - else if (modal === root.modalFirstRunIndex) - root.firstRunIndexModalRequested = true; else if (modal === root.modalLogUpload) root.logUploadModalRequested = true; else if (modal === root.modalQuitConfirm) @@ -308,17 +313,13 @@ MainLayout { // cascade needed by saved-screen restore and later drill-downs. root.hubScreen.restoreFromCategoriesReset(false); root._maybeArmHubResumeFocus(); - // Open the commercial-use notice on first paint of an unacked - // install. Sits in front of the media-DB first-run modal in the - // routing order — `_maybeOpenFirstRunIndex` early-returns until - // `Browse.Notice.commercial_ack` flips true, at which point the - // notice's close handler retriggers the media-DB check. + // Open the commercial-use notice on first paint of an unacked install. + // Indexing is independent of modal routing and can start behind it. root._maybeOpenCommercialNotice(); - // Kick the first-run check in case both READY and a seeded - // empty-mediadb snapshot landed before our Connections wired up - // (e.g. an unusually fast warm-cache reconnect). + // Kick the background first-run check in case READY, media status, and + // an empty catalog landed before our Connections wired up. root._maybeCompleteBoot(); - root._maybeOpenFirstRunIndex(); + root._maybeStartFirstRunIndex(); root._maybeStartStartupRestore(); } @@ -327,6 +328,12 @@ MainLayout { Browse.ImageOverrides.load_hub_overrides(); if (Browse.CategoriesModel.count > 0) root.hubScreen.restoreFromCategoriesReset(true); + // SystemsScreen's QML tree costs about a second to instantiate on + // MiSTer. Mount it just after Hub's first frame, while the user is + // orienting, instead of charging that one-time cost to first + // category Accept. Its cover requests remain disabled while + // inactive, so this warms structure without decoding SVG logos. + systemsScreenWarmMountTimer.restart(); root._maybeStartStartupRestore(); } } @@ -410,6 +417,18 @@ MainLayout { } root._restoreGamesScreenSelection(); } + // Same-sized folder pages update existing delegates rather than + // emitting modelReset. Restore persisted selection from this explicit + // revision edge so optimized Back navigation keeps identical behavior. + function onRows_revisionChanged(): void { + if (root.gamesScreen === null) { + root._whenScreenReady(root.screenGames, function () { + root._restoreGamesScreenSelection(); + }); + return; + } + root._restoreGamesScreenSelection(); + } // Pages 2+ append rows via begin_insert_rows / end_insert_rows // (no model reset), so we can't piggy-back on onModelReset to // retry the lookup. `count` bumps on every append, giving us a @@ -453,15 +472,29 @@ MainLayout { return; } if (Browse.GamesModel.has_next_page) { - // fetch_more is itself debounced by `loading_more` and - // `has_next_page`, so a redundant call here is a cheap - // no-op rather than a duplicate request. - Browse.GamesModel.fetch_more(); + // Restoration is not user-visible page navigation: bulk-load + // up to Core's 300-row limit so a saved page deep in a large + // parent does not require dozens of sequential 10-row RPCs. + // The loading gate remains up and bulk insertion skips the + // frame-gapped visible-page trickle. + Browse.GamesModel.fetch_more_restore(); return; } root._pendingGameRestorePath = ""; root._maybeFinishStartupGamesRestore(); } + // `apply_append_page` intentionally publishes terminal + // has_next_page=false after countChanged so pending grid jumps see the + // fresh row count. A restore target that no longer exists therefore + // cannot finish from onCountChanged: it still sees the prior true + // value and its guarded follow-up fetch is rejected because the final + // cursor is already empty. Recheck on the terminal edge to clear the + // loading gate instead of leaving "Loading games…" stuck forever. + function onHas_next_pageChanged(): void { + if (root._pendingGameRestorePath === "" || Browse.GamesModel.has_next_page || Browse.GamesModel.loading_more) + return; + root._restoreGamesScreenSelection(); + } } // Cross-screen transitions: each screen signals its intent and this @@ -469,10 +502,59 @@ MainLayout { // launch-resume persistence. Keeps the screens themselves ignorant // of AppState so they can be reused in test harnesses that don't // wire the full persistence layer. + function _beginTransitionTiming(action: string): void { + root._transitionInputStartedAt = Date.now(); + root._transitionRouteAt = 0; + root._transitionAction = action; + root._transitionFromScreen = root.activeScreen; + root._transitionToScreen = ""; + } + + function _markTransitionRouted(screen: string): void { + if (root._transitionInputStartedAt <= 0 || screen === root._transitionFromScreen) + return; + root._transitionRouteAt = Date.now(); + root._transitionToScreen = screen; + console.info("responsiveness transition routed" + " action=" + root._transitionAction + " from=" + root._transitionFromScreen + " to=" + screen + " route_ms=" + Math.max(0, root._transitionRouteAt - root._transitionInputStartedAt)); + } + + function _finishTransitionTiming(): void { + if (root._transitionToScreen === "" || root._transitionRouteAt <= 0) + return; + const presentedAt = Date.now(); + console.info("responsiveness transition presented" + " action=" + root._transitionAction + " from=" + root._transitionFromScreen + " to=" + root._transitionToScreen + " route_ms=" + Math.max(0, root._transitionRouteAt - root._transitionInputStartedAt) + " present_ms=" + Math.max(0, presentedAt - root._transitionRouteAt) + " total_ms=" + Math.max(0, presentedAt - root._transitionInputStartedAt)); + root._transitionInputStartedAt = 0; + root._transitionRouteAt = 0; + root._transitionAction = ""; + root._transitionFromScreen = ""; + root._transitionToScreen = ""; + } + + onFramePresented: { + root._finishTransitionTiming(); + if (!root.systemsCoverRevealReady && root.activeScreen === root.screenSystems && root.pendingTransition === "") { + root.systemsCoverRevealReady = true; + console.debug("responsiveness system covers enabled after destination frame"); + } + if (!root.gamesCoverRevealReady && root.activeScreen === root.screenGames && !Browse.GamesModel.loading && root.pendingTransition === "") { + const presentedAt = Date.now(); + root.gamesCoverRevealReady = true; + console.debug("responsiveness game covers enabled after model frame"); + if (root.gamesNavigationInputAt > 0) { + const modelReadyAt = root.gamesNavigationModelReadyAt > 0 ? root.gamesNavigationModelReadyAt : presentedAt; + console.info("responsiveness folder navigation presented" + " action=" + root.gamesNavigationAction + " model_ms=" + Math.max(0, modelReadyAt - root.gamesNavigationInputAt) + " present_ms=" + Math.max(0, presentedAt - modelReadyAt) + " total_ms=" + Math.max(0, presentedAt - root.gamesNavigationInputAt)); + root.gamesNavigationInputAt = 0; + root.gamesNavigationModelReadyAt = 0; + root.gamesNavigationAction = ""; + } + } + } + function _goto(screen: string): void { root._requestScreen(screen); root._startupTrace("startup/qml goto", "from=" + root.activeScreen, "to=" + screen, "pendingTransition=" + root.pendingTransition); ScreenManager.activeScreen = screen; + root._markTransitionRouted(screen); if (root._isLaunchResumeScreen(screen)) Browse.AppState.active_screen = screen; } @@ -578,19 +660,11 @@ MainLayout { // any navigation that starts a new browse target so a stale // restore can't keep paginating after the user moves on. property string _pendingGameRestorePath: "" + gamesSelectionRestorePending: root._pendingGameRestorePath !== "" property string _backTransitionTarget: "" property string _pendingFolderBackTargetPath: "" property string _pendingFolderBackSystemId: "" property var _folderBackReadyCallback: null - // System-cover prefetch gate. `_prefetchSystemCovers` populates - // `_systemCoverPrefetchUrls` with the first-page logos and stores the - // completion callback in `_systemCoverPrefetchCallback`. When every - // Image signals Ready/Error (or `systemCoverPrefetchTimer` expires), - // `_completePrefetchSystemCovers` fires the callback and clears state. - property var _systemCoverPrefetchUrls: [] - property var _systemCoverPrefetchCallback: null - property int _systemCoverPrefetchPending: 0 - function _catalogStillBooting(): bool { return !Browse.CategoriesModel.loaded && (Browse.CategoriesModel.error_message ?? "") === ""; } @@ -701,6 +775,8 @@ MainLayout { function onLoadingChanged(): void { if (Browse.GamesModel.loading) return; + if (root.gamesNavigationInputAt > 0 && root.gamesNavigationModelReadyAt <= 0) + root.gamesNavigationModelReadyAt = Date.now(); if (root._deferredSystemPending) { root._startupTrace("startup/qml system loading edge ignored", "reason=deferred-pending systemId=" + Browse.GamesModel.current_system_id + " count=" + Browse.GamesModel.count); return; @@ -751,6 +827,10 @@ MainLayout { } Connections { target: root._favoriteSystemsModelConnectionsEnabled ? Browse.FavoriteSystemsModel : null + function onModelReset(): void { + if (root.favoriteSystemsScreen !== null) + root.favoriteSystemsScreen.restoreSelection(); + } function onLoadingChanged(): void { if (Browse.FavoriteSystemsModel.loading) return; @@ -823,8 +903,8 @@ MainLayout { // resumable history row. Otherwise: tentatively pin the // destination to Systems, fill the chosen category, then either // bypass to Games (MiSTer Arcade singleton) or fall through to - // Systems with a cover-prefetch warmup so the destination paints - // with logos already in QPixmapCache. + // Systems immediately. Systems paints one stable frame with cover + // requests gated, then enables SVG decoding after that frame swaps. function _navigateFromHub(category: string): void { if (category === "") { root._goto(root.screenSystems); @@ -850,9 +930,8 @@ MainLayout { root._completeTransition(root.screenGames); }); } else { - root._prefetchSystemCovers(function () { - root._completeTransition(root.screenSystems); - }); + root.systemsCoverRevealReady = false; + root._completeTransition(root.screenSystems); } }, true); } @@ -1103,7 +1182,7 @@ MainLayout { if (savedPath !== "" && Browse.GamesModel.has_next_page) { root._pendingGameRestorePath = savedPath; root._setGamesRestoreIndex(0); - Browse.GamesModel.fetch_more(); + Browse.GamesModel.fetch_more_restore(); return false; } root._pendingGameRestorePath = ""; @@ -1126,6 +1205,9 @@ MainLayout { // gamesScreen.onRequestSystemsScreen below) so this path needs // no per-transition flag. function _navigateFromSystems(systemId: string): void { + root.gamesNavigationInputAt = 0; + root.gamesNavigationModelReadyAt = 0; + root.gamesNavigationAction = ""; root._requestScreen(root.screenGames); Browse.SystemsState.system_id = systemId; // Setting system_id on GamesState resets path_stack/selected_at_level @@ -1133,12 +1215,22 @@ MainLayout { // initial games-screen view, regardless of where the user was in // a prior system's folder tree. Browse.GamesState.system_id = systemId; + root.gamesCoverRevealReady = false; root.pendingTransition = "games"; root._ensureSystem(systemId, function () { root._completeTransition(root.screenGames); }); } + function _beginFolderNavigationTiming(action: string): void { + const screenInputAt = root.gamesScreen !== null ? root.gamesScreen.lastNavigationInputAt : 0; + root.gamesNavigationInputAt = screenInputAt > 0 ? screenInputAt : Date.now(); + root.gamesNavigationModelReadyAt = 0; + root.gamesNavigationAction = action; + if (root.gamesScreen !== null) + root.gamesScreen.lastNavigationInputAt = 0; + } + // Folder drill-down inside the games screen. Stays on screenGames // — no pendingTransition flip — so the in-screen ScreenStateOverlay // handles the loading/empty/error cue while the new browse settles. @@ -1147,11 +1239,14 @@ MainLayout { function _navigateIntoFolder(path: string): void { if (path === "") return; + root._beginFolderNavigationTiming("forward"); Browse.GamesState.push_level(path, ""); + root.gamesCoverRevealReady = false; Browse.GamesModel.set_path(path); } function _rebrowseGamesFolderTarget(path: string, systemId: string): void { + root.gamesCoverRevealReady = false; if (path === "") { if (systemId !== "") Browse.GamesModel.set_system(systemId); @@ -1168,6 +1263,7 @@ MainLayout { const stack = Browse.GamesState.path_stack; if (stack.length <= 1) return; + root._beginFolderNavigationTiming("back"); Browse.GamesState.pop_level(); const newStack = Browse.GamesState.path_stack; const target = newStack[newStack.length - 1]; @@ -1749,7 +1845,7 @@ MainLayout { }); entries.push({ id: "qr_code", - label: qsTr("QR code") + label: qsTr("Write with QR code") }); if (Browse.Settings.current_discover_arcade_alternate_versions) { entries.push({ @@ -2083,60 +2179,44 @@ MainLayout { ScreenManager.popModal(); } - // First-run modal lifecycle. Push exactly once per session, the - // moment the catalog resolves Ready and reports zero *indexed* - // systems (`CategoriesModel.loaded === true && indexed_count === 0`). - // We gate on `indexed_count`, not `count`: since Core's launchables - // feature, a device with no mediadb still returns launch-only virtual - // systems (non-empty `zapScript`) that land in the `Other` category, - // so `count`/`raw_count` are non-zero even when nothing is indexed. - // `indexed_count` ignores launchables, so it answers "are there - // indexed games to show?" — which is exactly the first-run question. - // The `loaded` gate is critical: the singleton's Default state has - // `indexed_count: 0` before the catalog fetch lands, so without it - // we'd fire the modal on cold launch before Core has answered. Gating - // on the catalog instead of MediaStatus.exists/seeded avoids the case - // where Core reports `database.exists: true` for an empty file. - function _maybeOpenFirstRunIndex(): void { - if (root._firstRunIndexShown) - return; - // Defer to the commercial-use notice. The notice's close handler - // calls back into here once acked, so chaining is automatic and - // we avoid stacking two modals at the same time. - if (!Browse.Notice.commercial_ack) - return; - // Never open while the Core-version warning is still on screen — - // `_coreVersionWarningShown` flips true when the warning *opens*, so - // without this guard a model signal arriving before the user - // dismisses it would stack the first-run modal on top. - if (root.coreVersionModalVisible) - return; - // Defer to the Core-version warning, which sits between the notice - // and this modal in the chain. Until that gate has resolved (shown - // or skipped, flipping `_coreVersionWarningShown`), hand off to it - // and let it call back here — so the two never stack and the - // warning always comes first. - if (!root._coreVersionWarningShown) { - root._maybeOpenCoreVersionWarning(); - return; - } - if (Browse.AppStatus.connection_state !== 2) - return; - if (!Browse.CategoriesModel.loaded) - return; - if (Browse.CategoriesModel.indexed_count > 0) + // First-run indexing is background work, not a navigation gate. Start once + // after both media status and the catalog are authoritative and the catalog + // reports no indexed systems. `indexed_count` deliberately ignores + // launch-only virtual systems, which can already make Hub non-empty. + function _shouldStartFirstRunIndex(connectionState: int, mediaStatusSeeded: bool, catalogLoaded: bool, indexedCount: int): bool { + return !root._firstRunIndexStarted && connectionState === 2 && mediaStatusSeeded && catalogLoaded && indexedCount === 0; + } + + function _maybeStartFirstRunIndex(): void { + if (!root._shouldStartFirstRunIndex(Browse.AppStatus.connection_state, Browse.MediaStatus.seeded, Browse.CategoriesModel.loaded, Browse.CategoriesModel.indexed_count)) return; - root._firstRunIndexShown = true; - root._requestModal(root.modalFirstRunIndex); - root.firstRunIndexModalVisible = true; - if (ScreenManager.topModal !== root.modalFirstRunIndex) - ScreenManager.pushModal(root.modalFirstRunIndex); + root._firstRunIndexStarted = true; + if (!Browse.MediaStatus.indexing && !Browse.MediaStatus.optimizing) + Browse.MediaStatus.start_index(); } - function closeFirstRunIndexModal(): void { - root.firstRunIndexModalVisible = false; - if (ScreenManager.topModal === root.modalFirstRunIndex) - ScreenManager.popModal(); + function _catalogRefreshScreenActive(): bool { + return root.activeScreen === root.screenHub || root.activeScreen === root.screenSystems || root.activeScreen === root.screenFavoriteSystems; + } + + function _refreshCatalogDuringIndex(): void { + if (root.activeScreen === root.screenFavoriteSystems) + Browse.FavoriteSystemsModel.retry(); + else + Browse.CategoriesModel.refresh(); + } + + // Poll only while an index is actively discovering content and only on + // screens that display category/system membership. Completion still gets + // the Store's MEDIA_DB invalidation refetch, so this timer is progressive + // presentation rather than correctness machinery. + Timer { + id: catalogIndexRefreshTimer + + interval: 5000 + repeat: true + running: Browse.MediaStatus.indexing && root._catalogRefreshScreenActive() + onTriggered: root._refreshCatalogDuringIndex() } // Commercial-use first-run notice. Persisted ack lives in @@ -2168,10 +2248,7 @@ MainLayout { root.commercialNoticeModalVisible = false; if (ScreenManager.topModal === root.modalCommercialNotice) ScreenManager.popModal(); - // Now that the notice is dismissed, advance the first-run chain: - // commercial notice → Core-version warning → media-DB first run. - // Each gate early-returns until its own condition holds, so this - // is safe to call unconditionally. + // Now that the notice is dismissed, advance to the Core-version warning. root._maybeOpenCoreVersionWarning(); } @@ -2183,20 +2260,14 @@ MainLayout { // has answered; `core_version_supported` defaults true so we never // flash the warning pre-check. function _maybeOpenCoreVersionWarning(): void { - if (root._coreVersionWarningShown) { - // Already handled this session — make sure the next gate still - // runs so a re-entry from another trigger doesn't stall the chain. - root._maybeOpenFirstRunIndex(); + if (root._coreVersionWarningShown) return; - } if (!Browse.Notice.commercial_ack) return; if (!Browse.AppStatus.core_version_checked) return; if (Browse.AppStatus.core_version_supported) { - // Version is fine — skip straight to the media-DB gate. root._coreVersionWarningShown = true; - root._maybeOpenFirstRunIndex(); return; } root._coreVersionWarningShown = true; @@ -2210,8 +2281,6 @@ MainLayout { root.coreVersionModalVisible = false; if (ScreenManager.topModal === root.modalCoreVersion) ScreenManager.popModal(); - // Advance to the media-DB first-run check. - root._maybeOpenFirstRunIndex(); } function openRandomFailedModal(): void { @@ -2489,8 +2558,6 @@ MainLayout { function stageSettingRestart(fieldId: string, selectedId: string): void { if (fieldId === "language") root._pendingLanguageSelection = selectedId; - else if (fieldId === "resolution") - root._pendingResolutionSelection = selectedId; else if (fieldId === "crtVideoStandard") root._pendingCrtStandardSelection = selectedId; root.openSettingNeedsRestartModal(); @@ -2503,7 +2570,6 @@ MainLayout { function cancelPendingRestart(): void { root._pendingLanguageSelection = ""; - root._pendingResolutionSelection = ""; root._pendingCrtStandardSelection = ""; root._pendingCrtToggle = ""; root.closeSettingNeedsRestartModal(); @@ -2525,16 +2591,12 @@ MainLayout { return; } const language = root._pendingLanguageSelection; - const resolution = root._pendingResolutionSelection; const crtStandard = root._pendingCrtStandardSelection; root._pendingLanguageSelection = ""; - root._pendingResolutionSelection = ""; root._pendingCrtStandardSelection = ""; root.closeSettingNeedsRestartModal(); if (language !== "") Browse.Settings.set_language(language); - if (resolution !== "") - Browse.Settings.set_resolution(resolution); if (crtStandard !== "") { Browse.CrtVideo.set_video_standard(crtStandard); // A standard change must respawn through Main_MiSTer (exit @@ -2713,12 +2775,7 @@ MainLayout { Browse.Settings.set_system_logo_style(selectedId); else if (fieldId === "buttonLayout") Browse.Settings.set_button_layout(selectedId); - else if (fieldId === "resolution") { - root.closeListPickerModal(); - if (selectedId !== Browse.Settings.current_resolution) - root.stageSettingRestart(fieldId, selectedId); - return; - } else if (fieldId === "screensaverTimeout") + else if (fieldId === "screensaverTimeout") Browse.Settings.set_screensaver_timeout(selectedId); else if (fieldId === "mediaImageType") Browse.Settings.set_media_image_type(selectedId); @@ -2772,7 +2829,7 @@ MainLayout { Connections { target: Browse.AppStatus function onConnection_stateChanged(): void { - root._maybeOpenFirstRunIndex(); + root._maybeStartFirstRunIndex(); root._maybeCompleteBoot(); root._maybeStartStartupRestore(); root._maybeCompletePendingResumeLaunch(); @@ -2784,6 +2841,13 @@ MainLayout { } } + Connections { + target: Browse.MediaStatus + function onSeededChanged(): void { + root._maybeStartFirstRunIndex(); + } + } + // One-shot dismiss for the cold-launch curtain. The first time the // catalog reports READY we flip `bootComplete` and never reset it // — a later disconnect surfaces only via the status pill so the @@ -2808,18 +2872,20 @@ MainLayout { Connections { target: Browse.CategoriesModel function onLoadedChanged(): void { - root._maybeOpenFirstRunIndex(); + root._maybeStartFirstRunIndex(); root._maybeStartStartupRestore(); root._maybeContinueOptimisticTransitions(); } function onCountChanged(): void { - root._maybeOpenFirstRunIndex(); + root._maybeStartFirstRunIndex(); root._maybeStartStartupRestore(); root._maybeContinueOptimisticTransitions(); } + function onIndexed_countChanged(): void { + root._maybeStartFirstRunIndex(); + } } - onCloseFirstRunIndexRequested: root.closeFirstRunIndexModal() onCloseCommercialNoticeRequested: root.closeCommercialNoticeModal() onCloseCoreVersionRequested: root.closeCoreVersionModal() onCloseRandomFailedRequested: root.closeRandomFailedModal() @@ -2900,6 +2966,8 @@ MainLayout { root._startupTrace("input/qml drop", "reason=pending-transition", "action=" + action, "pendingTransition=" + root.pendingTransition, "transitionCueVisible=" + root.transitionCueVisible); return; } + if (!ScreenManager.hasModal) + root._beginTransitionTiming(action); if (ScreenManager.hasModal) { // Single-consumer dispatch. When a second modal lands // (action_error variant for game launch / settings reset @@ -2920,9 +2988,6 @@ MainLayout { } else if (ScreenManager.topModal === root.modalContextMenu) { if (root.contextMenu !== null) root.contextMenu.handleAction(action); - } else if (ScreenManager.topModal === root.modalFirstRunIndex) { - if (root.firstRunIndexModal !== null) - root.firstRunIndexModal.handleAction(action); } else if (ScreenManager.topModal === root.modalCommercialNotice) { if (root.commercialNoticeModal !== null) root.commercialNoticeModal.handleAction(action); @@ -2998,6 +3063,10 @@ MainLayout { readonly property int _repeatInitialMs: 350 readonly property int _repeatTickMs: 90 readonly property int _rapidNavigationQuietMs: 260 + // Tap-only entry is deliberately difficult: normal alternating navigation + // must not suspend covers or show the letter overlay. A held direction + // bypasses this threshold on its first controlled repeat tick. + readonly property int _rapidNavigationTapThreshold: 4 // Window for collapsing a second delivery of the same key into one // press — hardware contact bounce or input-stack double send. Far // below _repeatInitialMs and the repeat tick so it never touches @@ -3115,10 +3184,17 @@ MainLayout { const sameBurst = rapidNavigationQuiet.running && root.rapidNavigationAction === action; root._rapidNavigationTapCount = sameBurst ? root._rapidNavigationTapCount + 1 : 1; root.rapidNavigationAction = action; - if (forceActive || rapidNavigationQuiet.running) + // Direction changes cancel tap-driven rapid mode immediately. Only a + // sustained hold (forceActive from repeat timer) or four consecutive + // same-direction taps inside the quiet window can enter it. + if (!sameBurst && !forceActive) { + root.rapidNavigationActive = false; + root.rapidNavigationIndicatorActive = false; + } + if (forceActive || root._rapidNavigationTapCount >= root._rapidNavigationTapThreshold) { root.rapidNavigationActive = true; - if (forceActive || root._rapidNavigationTapCount >= 3) root.rapidNavigationIndicatorActive = true; + } rapidNavigationQuiet.restart(); } @@ -3146,6 +3222,24 @@ MainLayout { // arms the initial-delay timer. Pulled out of handleKey so unit // tests can drive the repeat state machine without also routing // through handleAction → real screens. No-op for non-dpad actions. + function _prepareRapidNavigationSnapshot(action: string): void { + // Keep the pre-activation capture stable once rapid mode is showing; + // recapturing then would grab the intentionally hidden live cell layer. + if (root.rapidNavigationActive || !root._isRapidNavigationAction(action)) + return; + let screen = null; + if (root.activeScreen === root.screenGames) + screen = root.gamesScreen; + else if (root.activeScreen === root.screenFavorites) + screen = root.favoritesScreen; + else if (root.activeScreen === root.screenFavoriteSystems) + screen = root.favoriteSystemsScreen; + else if (root.activeScreen === root.screenRecents) + screen = root.recentsScreen; + if (screen !== null) + screen.prepareRapidSnapshot(); + } + function _armRepeat(action: string, key: int): void { if (!root._isRepeatableAction(action)) return; @@ -3153,6 +3247,7 @@ MainLayout { root._heldAction = action; root._heldKey = key; repeatTick.stop(); + root._prepareRapidNavigationSnapshot(action); repeatInitial.restart(); } @@ -3437,80 +3532,18 @@ MainLayout { } } } - - // Hidden Image pool driven by `_prefetchSystemCovers`. Each Image - // renders the tinted SVG logo off-screen at the same sourceSize as - // the visible Tile so they share one QPixmapCache entry. When every - // Image signals Ready or Error, `_systemCoverPrefetchPending` reaches - // zero and `_completePrefetchSystemCovers` fires the transition - // callback. `_systemCoverPrefetchUrls` is reset to [] when the gate - // resolves, which destroys the delegates immediately. No background - // fill is added — this Item is already a transparent overlay. - Repeater { - model: root._systemCoverPrefetchUrls - delegate: Image { - required property url modelData - source: modelData - sourceSize.width: 256 - asynchronous: true - visible: false - width: 0 - height: 0 - onStatusChanged: { - if (status !== Image.Ready && status !== Image.Error) - return; - root._systemCoverPrefetchPending = Math.max(0, root._systemCoverPrefetchPending - 1); - if (root._systemCoverPrefetchPending <= 0) - root._completePrefetchSystemCovers(); - } - } - } } - // System-cover prefetch gate. Holds the "Loading systems…" transition - // overlay until the first visible page of tinted SVG logos has decoded - // (or the cap timer fires), then calls cb(). This ensures the Systems - // grid reveals fully painted instead of showing name-text placeholders - // that pop into logos one-by-one. Fast/re-entry navigations complete - // within the 300ms DelayedLoadingIndicator threshold so no cue appears. - // The hidden prefetch Repeater lives in the transition-cue Item above; - // it watches `_systemCoverPrefetchUrls` and reports back via - // `_systemCoverPrefetchPending`. - function _prefetchSystemCovers(cb): void { - const pageSize = Sizing.visibleCovers * 4; - const count = Math.min(Browse.SystemsModel.count, pageSize); - if (count === 0) { - cb(); - return; - } - const urls = []; - for (let i = 0; i < count; ++i) { - const key = Browse.SystemsModel.cover_key_at(i); - // Warm both the unfocused and focused tint ramps up front so the - // first d-pad move never triggers an async SVG re-render. - const unfocusedUrl = Resources.coverUrl(key, Theme.logoPrimary, Theme.logoSecondary, Theme.logoShadow); - urls.push(unfocusedUrl); - const focusedUrl = Resources.coverUrl(key, Theme.logoFocusPrimary, Theme.logoFocusSecondary, Theme.logoFocusShadow); - // custom-image/ keys ignore tint params (served as-is), so both - // URLs are identical — skip the duplicate to avoid redundant fetches. - if (focusedUrl !== unfocusedUrl) { - urls.push(focusedUrl); + Timer { + id: systemsScreenWarmMountTimer + interval: 250 + repeat: false + onTriggered: { + if (!root.systemsScreenRequested) { + console.debug("responsiveness systems screen warm mount start"); + root.systemsScreenRequested = true; } } - root._systemCoverPrefetchCallback = cb; - root._systemCoverPrefetchPending = urls.length; - root._systemCoverPrefetchUrls = urls; - systemCoverPrefetchTimer.restart(); - } - - function _completePrefetchSystemCovers(): void { - systemCoverPrefetchTimer.stop(); - root._systemCoverPrefetchUrls = []; - root._systemCoverPrefetchPending = 0; - const cb = root._systemCoverPrefetchCallback; - root._systemCoverPrefetchCallback = null; - if (cb !== null) - cb(); } Timer { @@ -3569,19 +3602,6 @@ MainLayout { onTriggered: root._completeFolderBackTransition() } - // Safety cap for the system-cover prefetch gate. If some logos haven't - // decoded by this deadline they paint in after the screen reveals, - // identical to the Games cover-gate timeout behavior. Cap = 300ms - // (loadingIndicatorDelayMs) — logos that land faster than the - // DelayedLoadingIndicator threshold complete the transition silently; - // logos that are slower get a brief "Loading systems…" cue then pop in. - Timer { - id: systemCoverPrefetchTimer - interval: root.loadingIndicatorDelayMs - repeat: false - onTriggered: root._completePrefetchSystemCovers() - } - // Deferred set_category trigger. When the existing model has rows, // the caller stretches the interval to the delayed cue threshold plus // one frame so the transition indicator is visible before synchronous diff --git a/src/ui/app/MainLayout.qml b/src/ui/app/MainLayout.qml index fe122642..4bf74dbf 100644 --- a/src/ui/app/MainLayout.qml +++ b/src/ui/app/MainLayout.qml @@ -81,8 +81,16 @@ ApplicationWindow { property bool _headerMediaActivityEnabled: false property bool _firstFrameSeen: false readonly property bool _debugCrtSafeAreaGuideVisible: root.debugCrtSafeAreaOverlay && root.crtNativePath && Sizing.screenHeight <= 300 + + // Emitted for every presented frame. Main.qml uses this to close + // responsiveness timings on the first frame containing a destination + // screen; startup still uses `_firstFrameSeen` below. + signal framePresented property bool systemsScreenRequested: false property bool gamesScreenRequested: false + // Router-owned deep-page restoration gate. Keeps Games content hidden while + // cursor pages are appended until the persisted parent selection exists. + property bool gamesSelectionRestorePending: false property bool favoritesScreenRequested: false property bool favoriteSystemsScreenRequested: false property string favoritesSystemId: "" @@ -94,7 +102,6 @@ ApplicationWindow { property bool contextMenuRequested: false property bool qrCodeModalRequested: false property bool gameInfoModalRequested: false - property bool firstRunIndexModalRequested: false property bool commercialNoticeModalRequested: false property bool coreVersionModalRequested: false property bool randomFailedModalRequested: false @@ -190,12 +197,13 @@ ApplicationWindow { root.applyCrtPreviewScale(root._crtPreviewEffectiveScale); } onFrameSwapped: { - if (root._firstFrameSeen) - return; - root._firstFrameSeen = true; - root._statusIconsEnabled = true; - root._headerMediaActivityEnabled = true; - root._startupTrace("startup/qml firstFrameSwapped", "statusIconsEnabled=" + root._statusIconsEnabled, "mediaActivityEnabled=" + root._headerMediaActivityEnabled); + root.framePresented(); + if (!root._firstFrameSeen) { + root._firstFrameSeen = true; + root._statusIconsEnabled = true; + root._headerMediaActivityEnabled = true; + root._startupTrace("startup/qml firstFrameSwapped", "statusIconsEnabled=" + root._statusIconsEnabled, "mediaActivityEnabled=" + root._headerMediaActivityEnabled); + } } // When the window crosses to a different screen (e.g. dev drags @@ -277,7 +285,6 @@ ApplicationWindow { property var qrCodeModal: qrCodeModalLoader.item property var commercialNoticeModal: commercialNoticeModalLoader.item property var coreVersionModal: coreVersionModalLoader.item - property var firstRunIndexModal: firstRunIndexModalLoader.item property var gameInfoModal: gameInfoModalLoader.item property var logUploadModal: logUploadModalLoader.item property var quitConfirmModal: quitConfirmModalLoader.item @@ -298,7 +305,6 @@ ApplicationWindow { property bool commercialNoticeModalVisible: false property bool coreVersionModalVisible: false property bool randomFailedModalVisible: false - property bool firstRunIndexModalVisible: false property bool gameInfoModalVisible: false property bool logUploadModalVisible: false property bool quitConfirmModalVisible: false @@ -344,6 +350,19 @@ ApplicationWindow { readonly property int loadingIndicatorDelayMs: 300 readonly property int minimumLoadingVisibleMs: 200 property bool transitionCueVisible: false + // Systems destination paints one stable frame without SVG requests. Main + // flips this true from the following frame-presented callback so cover + // decoding cannot delay the navigation cut itself. + property bool systemsCoverRevealReady: true + // Games uses same progressive reveal for screen entry and in-screen folder + // replacement: model/card frame first, raster covers from following frame. + property bool gamesCoverRevealReady: true + // Folder navigation timing spans user input through model readiness and + // first presentation. Main.qml owns lifecycle; GamesScreen supplies input + // timestamp before synchronous state persistence. + property double gamesNavigationInputAt: 0 + property double gamesNavigationModelReadyAt: 0 + property string gamesNavigationAction: "" // Cold-launch boot gate. Non-Hub restores stay behind BootOverlay / // startupRestoreCurtain until the target can paint; Hub restores take the @@ -420,7 +439,6 @@ ApplicationWindow { signal closeCommercialNoticeRequested signal closeCoreVersionRequested signal closeRandomFailedRequested - signal closeFirstRunIndexRequested signal closeLogUploadRequested signal closeQuitConfirmRequested signal quitConfirmAccepted @@ -624,8 +642,9 @@ ApplicationWindow { // // Transition feedback is a delayed static LoadingIndicator, not // an animated screen effect. Quick swaps cut directly; slower - // model fills hide source content only after the loading cue is - // visible, avoiding both spinner flashes and pre-feedback freezes. + // model fills hide source rows/grids only after the loading cue is + // visible. Bottom selection context and help stay frozen until the + // destination cut so the source screen does not dismantle itself. // // The wrapper `Item` stays for grouping clarity; with no fade // machinery it carries no buffered state. Model bindings stay @@ -658,11 +677,14 @@ ApplicationWindow { anchors.fill: parent active: root.systemsScreenRequested visible: status === Loader.Ready && root.activeScreen === root.screenSystems + onLoaded: console.debug("responsiveness systems screen mounted") sourceComponent: Component { SystemsScreen { anchors.fill: parent transitioning: root.transitionCueVisible + preparingTransition: root.pendingTransition === "systems" active: root.activeScreen === root.screenSystems + coverRevealReady: root.systemsCoverRevealReady optimisticLoading: root.activeScreen === root.screenSystems && root.catalogStillBooting } } @@ -678,7 +700,8 @@ ApplicationWindow { anchors.fill: parent transitioning: root.transitionCueVisible active: root.activeScreen === root.screenGames - optimisticLoading: root.activeScreen === root.screenGames && root.catalogStillBooting + coverRevealReady: root.gamesCoverRevealReady + optimisticLoading: root.activeScreen === root.screenGames && (root.catalogStillBooting || root.gamesSelectionRestorePending) } } } @@ -891,23 +914,6 @@ ApplicationWindow { } } - // First-run mediadb index modal. Pushed by Main.qml the first time - // we connect to a Core whose mediadb is empty. Blocks the screens - // beneath until the initial scan completes (or the user cancels and - // tries again). - Loader { - id: firstRunIndexModalLoader - anchors.fill: parent - active: root.firstRunIndexModalRequested - sourceComponent: Component { - FirstRunIndexModal { - anchors.fill: parent - open: root.firstRunIndexModalVisible - onCloseRequested: root.closeFirstRunIndexRequested() - } - } - } - // Commercial-use notice. Sits above every other modal (z: 310) so // it always paints first on a fresh install. Once the user acks, // `Browse.Notice.commercial_ack` flips to true on disk and the @@ -962,7 +968,7 @@ ApplicationWindow { } // List-picker modal. Settings opens this for picker rows - // (Language, Browsing layout, Button style, Resolution). The + // (Language, Browsing layout, Button style, and others). The // fieldId round-trip lets the router dispatch the chosen id // back to the matching Browse.Settings.set_X without parsing // the title. @@ -1080,11 +1086,11 @@ ApplicationWindow { // Retry entry rather than promising behavior the screen // doesn't implement. // - // During a forward transition (`pendingTransition !== ""`) - // the router's input gate swallows every press — including - // cancel — so the bar blanks rather than advertising - // buttons that won't respond. Modals still win outright; - // they run on top of the input gate. + // During a forward transition the router's input gate still + // swallows presses, but the source help row stays frozen until + // the destination cut. Removing it early made the otherwise + // static source screen look as though it was dismantling while + // work continued. Modals still win outright. // // Each entry resolves to a button glyph (Dpad / ButtonA / // ButtonB / ButtonX) plus a label. The button names are routed @@ -1107,11 +1113,7 @@ ApplicationWindow { label: qsTr("Select") }, { - button: "ButtonB", - label: qsTr("Close") - }, - { - button: "ButtonX", + buttons: ["ButtonB", "ButtonX"], label: qsTr("Close") } ]; @@ -1205,26 +1207,6 @@ ApplicationWindow { ]; if ((!root.bootComplete && !root.coreIndependentStartupVisible) || root.startupRestoreCurtainVisible) return []; - if (root.firstRunIndexModalVisible) { - const phase = root.firstRunIndexModal ? root.firstRunIndexModal.phase : ""; - if (phase === "running") - return [ - { - button: "ButtonB", - label: qsTr("Cancel") - } - ]; - if (phase === "completed") - return []; - return [ - { - button: "ButtonA", - label: qsTr("Start") - } - ]; - } - if (root.pendingTransition !== "" || root.transitionCueVisible) - return []; if (root.activeScreen === root.screenHub) { // Hub always has the actions row (Recently Played / // Settings), so Move/Open/Quit applies even when the diff --git a/src/ui/components/BrowseDetailPane.qml b/src/ui/components/BrowseDetailPane.qml index e5c58a88..17357a03 100644 --- a/src/ui/components/BrowseDetailPane.qml +++ b/src/ui/components/BrowseDetailPane.qml @@ -325,9 +325,9 @@ Item { // Wordmark fallback for system entries with no curated logo SVG. // Mirrors the grid Tile's fitted-text treatment: DemiBold, logo-focus - // tint, shrinks to fill. Hidden while a logo is loading so the busy - // window is brief. The File chip above is suppressed for system keys - // (via !_isSystemCover) so exactly one of the two placeholders shows. + // tint, shrinks to fill. It appears only after terminal Image.Error, + // never during Null/Loading. The File chip above is suppressed for + // system keys so exactly one fallback can show. Text { objectName: "detailLogoWordmark" @@ -344,7 +344,7 @@ Item { horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering - visible: root._isSystemCover && !root._coverBusy && cover.status !== Image.Ready && root.title !== "" && !root.detailSuppressed + visible: root._isSystemCover && cover.status === Image.Error && root.title !== "" && !root.detailSuppressed clip: true } } diff --git a/src/ui/components/CMakeLists.txt b/src/ui/components/CMakeLists.txt index d1bbc902..4d5e7cc0 100644 --- a/src/ui/components/CMakeLists.txt +++ b/src/ui/components/CMakeLists.txt @@ -21,7 +21,6 @@ qt_add_qml_module( CoreStatusPill.qml CrtCalibrationModal.qml DelayedLoadingIndicator.qml - FirstRunIndexModal.qml FocusedMediaDetailController.qml GameInfoModal.qml HeaderBar.qml diff --git a/src/ui/components/CoreStatusPill.qml b/src/ui/components/CoreStatusPill.qml index dbb95d7a..428f2421 100644 --- a/src/ui/components/CoreStatusPill.qml +++ b/src/ui/components/CoreStatusPill.qml @@ -25,7 +25,11 @@ import Zaparoo.Theme // fill. Only this small item repaints while the spinner advances. Item { id: pill + objectName: "coreStatusPill" property bool mediaActivityEnabled: false + // Width available between the logo and right header edge. Zero means no + // external cap (useful while HeaderBar geometry is still settling). + property int maximumWidth: 0 Component.onCompleted: console.debug("startup/qml component CoreStatusPill completed") @@ -96,16 +100,24 @@ Item { // Border colour leans on the same convention as the old connection // strip: warmer accent for error-class link states, muted otherwise. readonly property bool _isError: Browse.AppStatus.link_state === pill._linkUnreachable || Browse.AppStatus.connection_state === pill._connError - readonly property int _mediaWidth: Theme.crtNativePath ? Sizing.pctH(42) : Math.min(Math.max(Sizing.pctH(28), Sizing.pctW(18)), Sizing.pctH(30)) + readonly property int _mediaMinimumWidth: Theme.crtNativePath ? Sizing.pctH(42) : Math.min(Math.max(Sizing.pctH(28), Sizing.pctW(18)), Sizing.pctH(30)) readonly property int _textMargin: Sizing.pctW(1.2) readonly property int _spinnerSize: Math.max(Sizing.pctH(1.8), Sizing.fontSize(2.2)) readonly property int _spinnerDotSize: Math.max(Sizing.stroke(2), Sizing.px(pill._spinnerSize / 3)) readonly property int _spinnerGap: Sizing.pctW(0.8) + readonly property int _labelNaturalWidth: Math.ceil(Math.max(labelMetrics.advanceWidth, labelMetrics.boundingRect.x + labelMetrics.boundingRect.width) - Math.min(0, labelMetrics.boundingRect.x)) + readonly property int _spinnerReservedWidth: pill._spinnerActive ? pill._spinnerSize + pill._spinnerGap : 0 + readonly property int _naturalWidth: pill._labelNaturalWidth + 2 * pill._textMargin + pill._spinnerReservedWidth + readonly property int _desiredWidth: pill._isMediaActivity ? Math.max(pill._mediaMinimumWidth, pill._naturalWidth) : pill._naturalWidth property int _spinnerFrame: 0 visible: pill._label !== "" height: pill.visible ? Sizing.fontSize(3.4) : 0 - width: pill.visible ? (pill._isMediaActivity ? pill._mediaWidth : Sizing.px(labelMetrics.implicitWidth + Sizing.pctW(2.4))) : 0 + width: pill.visible ? pill._boundedWidth(pill._desiredWidth) : 0 + + function _boundedWidth(desired: int): int { + return pill.maximumWidth > 0 ? Math.min(desired, pill.maximumWidth) : desired; + } function _spinnerDotX(index: int, size: int): int { if (index === 1) @@ -136,9 +148,9 @@ Item { onTriggered: pill._spinnerFrame = (pill._spinnerFrame + 1) % 4 } - Text { + TextMetrics { id: labelMetrics - visible: false + text: pill._label font.family: Theme.fontUi font.pixelSize: Sizing.fontSize(2.2) diff --git a/src/ui/components/HeaderBar.qml b/src/ui/components/HeaderBar.qml index b36d382e..56673ae4 100644 --- a/src/ui/components/HeaderBar.qml +++ b/src/ui/components/HeaderBar.qml @@ -96,6 +96,10 @@ Item { return date.toLocaleTimeString(header._clockLocale(), header._clockFormatString()); } + function _clockDateValid(date: date): bool { + return !isNaN(date.getTime()) && date.getFullYear() >= 2020; + } + function _clockMetricSample(): string { const sample = header._clockUses12Hour() ? new Date(2000, 0, 1, 12, 59) : new Date(2000, 0, 1, 23, 59); return header._clockText(sample); @@ -165,6 +169,7 @@ Item { property date currentDate: new Date() readonly property string currentTime: header._clockText(clockLabel.currentDate) + visible: header._clockDateValid(clockLabel.currentDate) anchors.verticalCenter: parent.verticalCenter height: parent.height width: Sizing.px(clockMetrics.advanceWidth) @@ -224,5 +229,9 @@ Item { anchors.right: topHud.right anchors.topMargin: header._headerProfile && header._headerProfile.statusPillPinnedTop ? 0 : Sizing.headerStackGap mediaActivityEnabled: header.mediaActivityEnabled + // Second HUD row has no clock/icons competing for width. Let status + // text use all space between logo and right edge instead of truncating + // every media state to the old fixed-width pill. + maximumWidth: Math.max(0, header.width - Sizing.headerSideMargin - (logo.x + logo.paintedWidth + Sizing.pctW(2))) } } diff --git a/src/ui/components/LetterJumpModal.qml b/src/ui/components/LetterJumpModal.qml index 992f4796..c8fbd6da 100644 --- a/src/ui/components/LetterJumpModal.qml +++ b/src/ui/components/LetterJumpModal.qml @@ -131,18 +131,22 @@ Item { return best; } - // Pure 2D grid move. Clamps within bounds; `down` past the last full row - // lands on the final cell so scanning down the alphabet always reaches the - // tail bucket. Kept side-effect free so it is unit-testable in isolation. + // Pure 2D grid move. Left/right wrap within the visible row rather than + // leaking into the adjacent row; a partial final row wraps across only its + // live cells. `down` past the last full row lands on the final cell so + // scanning down the alphabet always reaches the tail bucket. Kept + // side-effect free so it is unit-testable in isolation. function nextIndex(action: string, index: int, count: int, columns: int): int { if (count <= 0) return 0; const cols = Math.max(1, columns); + const rowStart = Math.floor(index / cols) * cols; + const rowEnd = Math.min(count - 1, rowStart + cols - 1); let next = index; if (action === "left") - next = index - 1; + next = index <= rowStart ? rowEnd : index - 1; else if (action === "right") - next = index + 1; + next = index >= rowEnd ? rowStart : index + 1; else if (action === "up") next = index - cols; else if (action === "down") { diff --git a/src/ui/components/ListPickerModal.qml b/src/ui/components/ListPickerModal.qml index 5cf8ad1d..01e5d6a9 100644 --- a/src/ui/components/ListPickerModal.qml +++ b/src/ui/components/ListPickerModal.qml @@ -131,7 +131,7 @@ Item { } else if (action === "accept") { if (modal.currentIndex >= 0 && modal.currentIndex < modal.entries.length) modal._commitAccept(modal.entries[modal.currentIndex].id); - } else if (action === "cancel") { + } else if (action === "cancel" || action === "page_menu") { modal.closeRequested(); } } diff --git a/src/ui/components/PagedGrid.qml b/src/ui/components/PagedGrid.qml index e0307f1a..b217934c 100644 --- a/src/ui/components/PagedGrid.qml +++ b/src/ui/components/PagedGrid.qml @@ -59,8 +59,24 @@ Item { // untouched. property bool focused: true property bool coverLoadingPaused: false + // False keeps delegate/cursor structure alive while withholding Image + // sources. Useful during a model replacement: unlike suspending Repeater, + // it avoids a synchronous teardown/rebuild while still preventing cold + // cover decodes behind a transition cue. + property bool coverRequestsEnabled: true property bool rapidRenderMode: false - readonly property int _coverRetentionPages: Math.max(1, Math.ceil(Sizing.visibleCovers)) + // Number of pages after current whose covers are decoded speculatively. + // Media grids keep one page warm; SVG-heavy systems grids can set zero and + // rely on their router-owned visible-page prefetch gate. + property int coverLookaheadPages: 1 + // When false, focused tint variants are requested only for selected tile. + // Normal grids preserve eager variants; systems opt out because every + // variant requires a separate SVG raster on MiSTer's small ARM CPU. + property bool eagerFocusedCovers: true + // Sizing.visibleCovers is a tile count, not a page count. Convert it through + // current column count; treating five covers as five whole pages retained up + // to 110 live Tile trees and made deep-page Back block Qt's main thread. + readonly property int _coverRetentionPages: Math.max(1, Math.ceil(Sizing.visibleCovers / Math.max(1, root.columns))) // Pulse counter for the one-shot tile push-in. Callers increment via // pulseActivate(); TileLoader forwards the value to Tile where only the // focused+selected delegate fires its cue. The same cue serves both @@ -88,6 +104,13 @@ Item { // so the default tile 0 never paints a ring before restore lands; default // true keeps focus rendering on for hosts that do not wire it. property bool focusReady: true + // Hide only cell delegates while retaining scrollbar chrome. Rapid-scroll + // snapshot mode uses this so the frozen grid replaces cells without making + // the live gutter disappear. + property bool cellsVisible: true + // Optional index -> string callback for a compact label above tile art. + // Empty/null keeps existing tile geometry unchanged. + property var tileTopLabelProvider: null property var layoutProfile: null readonly property var _gridProfile: root.layoutProfile && root.layoutProfile.grid ? root.layoutProfile.grid : null @@ -149,7 +172,7 @@ Item { // the user can actually navigate to right now, not a paginated // model's reported total. readonly property bool hasPagesAbove: currentPage > 0 - readonly property bool hasPagesBelow: currentPage < pageCount - 1 + readonly property bool hasPagesBelow: currentPage < pageCount - 1 || (!root.paginationTotalKnown && root.hasMorePages) // Caller-supplied total item count, used by the scroll thumb so its // size and position reflect the full dataset rather than the loaded @@ -161,6 +184,11 @@ Item { property int totalItemsOverride: -1 readonly property int totalItems: totalItemsOverride >= 0 ? totalItemsOverride : itemCount readonly property int totalPageCount: Math.max(1, Math.ceil(totalItems / pageSize)) + // False when a cursor chain has no authoritative final count. Navigation + // continues forward by requesting another page instead of wrapping at the + // current loaded edge; chrome keeps arrows but suppresses the misleading + // growing scrollbar thumb. + property bool paginationTotalKnown: true // Caller-supplied "more pages exist" flag for paginated models. // Drives the pending-target watchdog: if the model says no more pages @@ -227,8 +255,7 @@ Item { // rightmost cell and the gutter. `gutterGap` is intentionally // tighter than `cellSpacingX` — the scrollbar reads as chrome, // not as another cell, so a full inter-cell gap looks like wasted - // space next to it. The gutter stays reserved on a single page - // (just hidden) so cells don't reflow when paging activates. + // space next to it. Single-page grids reserve no invisible gutter. readonly property int leftInset: root._gridProfile ? root._gridProfile.leftInset : Sizing.pctW(5) readonly property int rightInset: root._gridProfile ? root._gridProfile.rightInset : Sizing.pctW(5) readonly property int gutterWidth: root._gridProfile ? root._gridProfile.gutterWidth : Sizing.pctW(3) @@ -241,14 +268,16 @@ Item { readonly property int bottomInset: root._gridProfile ? root._gridProfile.bottomInset : Sizing.pctH(2) readonly property int cellSpacingX: root._gridProfile ? root._gridProfile.columnGap : Sizing.pctW(3) readonly property int cellSpacingY: root._gridProfile ? root._gridProfile.rowGap : Sizing.pctH(4) + readonly property bool _scrollIndicatorVisible: root.paginationTotalKnown ? root.totalPageCount > 1 : (root.pageCount > 1 || root.hasMorePages) + readonly property int _activeGutterWidth: root._scrollIndicatorVisible ? root.gutterWidth : 0 + readonly property int _activeGutterGap: root._scrollIndicatorVisible ? root.gutterGap : 0 readonly property int _contentWidth: root.columns * root.cellWidth + (root.columns - 1) * root.cellSpacingX - readonly property int _scrollGutterX: root._gridProfile && root._gridProfile.gutterFollowsContentWidth ? root.leftInset + root._contentWidth + root.gutterGap : width - root.rightInset - root.gutterWidth + readonly property int _scrollGutterX: root._gridProfile && root._gridProfile.gutterFollowsContentWidth ? root.leftInset + root._contentWidth + root._activeGutterGap : width - root.rightInset - root.gutterWidth // Computed cell dimensions — fill the available area, divided by - // columns × rows. The cell area - // also reserves `gutterGap + gutterWidth` on the right for the - // tight-gap-then-scrollbar layout described above. - readonly property int _availableWidth: Math.max(0, width - leftInset - rightInset - gutterGap - gutterWidth) + // columns × rows. Multi-page layouts reserve `gutterGap + gutterWidth` on + // the right; single-page layouts return that otherwise-empty space to cells. + readonly property int _availableWidth: Math.max(0, width - leftInset - rightInset - root._activeGutterGap - root._activeGutterWidth) readonly property int _availableHeight: Math.max(0, height - topInset - bottomInset) readonly property int cellWidth: Math.max(0, Math.floor((root._availableWidth - (root.columns - 1) * root.cellSpacingX) / root.columns)) readonly property int cellHeight: Math.max(0, Math.floor((root._availableHeight - (root.rows - 1) * root.cellSpacingY) / root.rows)) @@ -257,6 +286,15 @@ Item { root.currentIndex = idx; } + // Disarm model-relative navigation before a folder/scope replacement can + // shrink row count. Resetting from a deep loaded page while Qt still owns a + // pending delegate index can make DelegateModel cancel an index against the + // new smaller count. Selection restoration runs after replacement. + function prepareForModelReplacement(): void { + root._clearPendingTarget(); + root.currentIndex = 0; + } + function _handleWheel(wheel: WheelEvent): void { const amount = wheel.angleDelta.y !== 0 ? wheel.angleDelta.y : wheel.pixelDelta.y; if (amount === 0) @@ -287,11 +325,23 @@ Item { // Returns true if the index changed synchronously, false if a // pending-jump was stashed or the dataset is single-page. function pageBy(delta: int): bool { - if (root.itemCount <= 0 || root.totalPageCount <= 1 || delta === 0) + if (root.itemCount <= 0 || delta === 0) return false; - const total = root.totalPageCount; - // JS `%` keeps sign on negatives — normalise into [0, total). - const targetPage = ((root.currentPage + delta) % total + total) % total; + let targetPage; + if (!root.paginationTotalKnown && root.hasMorePages) { + // No final page exists to wrap to yet. Backward paging stops at + // page zero; forward paging past the loaded edge requests the next + // cursor page below. + targetPage = root.currentPage + delta; + if (targetPage < 0) + return false; + } else { + if (root.totalPageCount <= 1) + return false; + const total = root.totalPageCount; + // JS `%` keeps sign on negatives — normalise into [0, total). + targetPage = ((root.currentPage + delta) % total + total) % total; + } if (targetPage === root.currentPage) return false; if (targetPage > root.pageCount - 1) { @@ -502,6 +552,8 @@ Item { const itemsOnPage = Math.min(root.pageSize, root.itemCount - root.currentPage * root.pageSize); const lastFilledRowOnPage = Math.floor((itemsOnPage - 1) / root.columns); if (rowCandidate < 0) { + if (!root.paginationTotalKnown && root.hasMorePages && root.currentPage === 0) + return false; const targetPage = root.currentPage === 0 ? root.totalPageCount - 1 : root.currentPage - 1; if (targetPage > root.pageCount - 1) { root._pendingTargetIndex = -1; @@ -515,7 +567,7 @@ Item { newRow = root.rows - 1; } else if (rowCandidate >= root.rows || rowCandidate > lastFilledRowOnPage) { const lastPage = root.totalPageCount - 1; - const targetPage = root.currentPage === lastPage ? 0 : root.currentPage + 1; + const targetPage = !root.paginationTotalKnown && root.hasMorePages && root.currentPage >= root.pageCount - 1 ? root.currentPage + 1 : (root.currentPage === lastPage ? 0 : root.currentPage + 1); if (targetPage > root.pageCount - 1) { root._pendingTargetIndex = -1; root._pendingTargetPage = targetPage; @@ -651,27 +703,27 @@ Item { readonly property int cellRow: Math.floor(cellLocal / root.columns) readonly property int cellCol: cellLocal % root.columns readonly property bool isSelected: index === root.currentIndex + readonly property string topLabel: typeof root.tileTopLabelProvider === "function" ? (root.tileTopLabelProvider(index) ?? "") : "" // Cover-decode gate AND delegate-materialisation gate. // PagedGrid's Repeater creates one cellItem per model // row at construction. Two-tier gate, both anchored on // distance from `root.currentPage`: // - // - decode range (current + next page): cells hand - // their real coverKey to Tile, forcing hidden - // next-page Image decode/QPixmapCache warm before - // the page cut. Rust still owns byte-fetch priority + // - decode range (current + configured lookahead pages): + // cells hand their real coverKey to Tile, forcing hidden + // next-page Image decode/QPixmapCache warm before the page + // cut when lookahead is enabled. Rust still owns byte-fetch priority // via `prefetch_around`; this range only makes QML // consume already-warmed bytes early enough. - // - retention range (±5 pages): cells inside this - // radius keep their TileLoader.active=true so the - // Tile delegate stays materialised, AND cells that - // have already requested keep their coverKey set - // so Tile's Image keeps the decoded texture - // referenced. The active gate is what prevents - // per-press binding cost from growing with the - // dataset - only ~110 Tile delegates exist at any - // time regardless of N. Retention doesn't trigger + // - retention range (current ± enough pages for one + // Sizing.visibleCovers span): cells inside this radius keep + // their TileLoader.active=true, AND cells that have already + // requested keep their coverKey set so Tile's Image keeps + // the decoded texture referenced. The active gate prevents + // per-press binding cost from growing with dataset size; + // only current and adjacent-page Tile delegates exist at + // normal grid densities. Retention doesn't trigger // new cover requests; only the decode range does. // // Off-radius cells (outside retention) set @@ -683,13 +735,13 @@ Item { // collapses to the procedural fallback and the // texture reference drops. // - // Memory ceiling tracks visible cover density: ± - // _coverRetentionPages around currentPage keeps enough - // decoded pages warm for the current UI scale. Re-decode + // Memory ceiling tracks visible cover density: + // _coverRetentionPages around currentPage keeps adjacent + // decoded pages warm for current UI scale. Re-decode // after crossing past the retention edge runs at // nice +10 (see media_image_provider.cpp) and is // invisible to the renderer. - readonly property bool _coverInRange: !root.rapidRenderMode && cellPage >= root.currentPage && cellPage <= root.currentPage + 1 + readonly property bool _coverInRange: root.coverRequestsEnabled && !root.rapidRenderMode && cellPage >= root.currentPage && cellPage <= root.currentPage + Math.max(0, root.coverLookaheadPages) readonly property bool _coverInRetentionRange: !root.rapidRenderMode && Math.abs(cellPage - root.currentPage) <= (root.coverLoadingPaused ? 1 : root._coverRetentionPages) property bool _coverEverRequested: false Binding on _coverEverRequested { @@ -706,7 +758,7 @@ Item { // Selected tile draws on top so its scale-up tween isn't // clipped by neighbours below/right of it. z: isSelected ? 1 : 0 - visible: cellPage === root.currentPage + visible: root.cellsVisible && cellPage === root.currentPage // Card-shaped placeholder painted behind the // TileLoader. When the loader's `active` is false @@ -794,6 +846,7 @@ Item { isFocused: root.focused name: cellItem.name coverKey: cellItem._gatedCoverKey + topLabel: cellItem.topLabel favorite: cellItem.favorite hidden: cellItem.hidden disambiguatingTags: cellItem.disambiguatingTags @@ -801,6 +854,7 @@ Item { releasePulse: root.releasePulse settling: root.screenSettling focusReady: root.focusReady + loadFocusedCover: root.eagerFocusedCovers || cellItem.isSelected } MouseArea { @@ -850,7 +904,7 @@ Item { anchors.bottom: parent.bottom anchors.bottomMargin: root.bottomInset width: root.gutterWidth - visible: root.totalPageCount > 1 + visible: root._scrollIndicatorVisible Image { id: upArrow @@ -918,6 +972,7 @@ Item { Rectangle { id: scrollThumb + visible: root.paginationTotalKnown width: root.scrollThumbWidth height: scrollRegion._thumbHeight anchors.right: root.scrollThumbRightAligned ? parent.right : undefined diff --git a/src/ui/components/QrCodeModal.qml b/src/ui/components/QrCodeModal.qml index 014a2124..92e4d55e 100644 --- a/src/ui/components/QrCodeModal.qml +++ b/src/ui/components/QrCodeModal.qml @@ -20,74 +20,83 @@ Item { property int quietZone: 4 readonly property int matrixSize: Browse.QrCode.size - readonly property int maxQrPixels: Math.min(Sizing.pctW(42), Sizing.pctH(68)) + readonly property int maxQrPixels: Math.min(Sizing.pctW(36), Sizing.pctH(48)) readonly property int moduleSize: matrixSize > 0 ? Math.max(1, Math.floor(maxQrPixels / (matrixSize + quietZone * 2))) : 1 readonly property int qrPixels: moduleSize * (matrixSize + quietZone * 2) visible: root.open z: 300 - // Full-screen scrim. Joins QrCodeModal to the modal family for now - // (full panel chrome — title, padding, close affordance — is a - // future round). The MouseArea below eats clicks/hover so the - // dimmed screens beneath don't track focus under the modal. - Rectangle { - anchors.fill: parent - color: Theme.scrim - - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.AllButtons - } - } + Modal { + id: shell + + open: root.open + kind: "shell" + title: qsTr("Write with QR code") + panelMaxWidth: Sizing.pctH(105) + + Column { + width: parent.width + spacing: Sizing.pctH(2) + + Text { + width: parent.width + text: qsTr("Scan this code with your phone to write this game to a Zaparoo token.") + font.family: Theme.fontUi + font.pixelSize: Sizing.fontSize(2.4) + color: Theme.textPrimary + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + renderType: Text.NativeRendering + } - Rectangle { - x: Sizing.center(parent.width, width) - y: Sizing.center(parent.height, height) - width: root.qrPixels - height: root.qrPixels - color: "white" - border.width: Sizing.stroke(root.moduleSize * 0.18) - border.color: Theme.borderSubtle + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: root.qrPixels + height: root.qrPixels + color: "white" + border.width: Sizing.stroke(root.moduleSize * 0.18) + border.color: Theme.borderSubtle - Item { - id: matrix + Item { + id: matrix - x: Sizing.center(parent.width, width) - y: Sizing.center(parent.height, height) - width: root.moduleSize * root.matrixSize - height: root.moduleSize * root.matrixSize - visible: root.matrixSize > 0 + x: Sizing.center(parent.width, width) + y: Sizing.center(parent.height, height) + width: root.moduleSize * root.matrixSize + height: root.moduleSize * root.matrixSize + visible: root.matrixSize > 0 - Repeater { - model: root.matrixSize + Repeater { + model: root.matrixSize - delegate: Item { - id: rowDelegate + delegate: Item { + id: rowDelegate - required property int index + required property int index - readonly property int row: index - readonly property string bits: Browse.QrCode.row_at(row) + readonly property int row: index + readonly property string bits: Browse.QrCode.row_at(row) - x: 0 - y: row * root.moduleSize - width: matrix.width - height: root.moduleSize + x: 0 + y: row * root.moduleSize + width: matrix.width + height: root.moduleSize - Repeater { - model: root.matrixSize + Repeater { + model: root.matrixSize - delegate: Rectangle { - required property int index + delegate: Rectangle { + required property int index - x: index * root.moduleSize - y: 0 - width: root.moduleSize - height: root.moduleSize - color: "black" - visible: rowDelegate.bits.charAt(index) === "1" + x: index * root.moduleSize + y: 0 + width: root.moduleSize + height: root.moduleSize + color: "black" + visible: rowDelegate.bits.charAt(index) === "1" + } + } } } } diff --git a/src/ui/components/ScrollingCaption.qml b/src/ui/components/ScrollingCaption.qml index a75cb84d..e496dfe0 100644 --- a/src/ui/components/ScrollingCaption.qml +++ b/src/ui/components/ScrollingCaption.qml @@ -52,8 +52,13 @@ Item { readonly property int _gapW: root._hasTags ? Sizing.pctW(1.2) : 0 readonly property int _avail: Math.max(0, root.width) - readonly property int _nameFullW: Math.ceil(nameMetrics.advanceWidth) - readonly property int _tagsFullW: root._hasTags ? Math.ceil(tagsMetrics.advanceWidth) : 0 + // advanceWidth measures cursor movement, not every painted pixel. Native, + // fully hinted glyphs can extend beyond either side bearing, so using only + // advanceWidth can classify a visibly clipped caption as fitting. Measure + // the union of logical advance and painted bounds for overflow/marquee + // decisions; this also absorbs fractional metrics before integer layout. + readonly property int _nameFullW: Math.ceil(Math.max(nameMetrics.advanceWidth, nameMetrics.boundingRect.x + nameMetrics.boundingRect.width) - Math.min(0, nameMetrics.boundingRect.x)) + readonly property int _tagsFullW: root._hasTags ? Math.ceil(Math.max(tagsMetrics.advanceWidth, tagsMetrics.boundingRect.x + tagsMetrics.boundingRect.width) - Math.min(0, tagsMetrics.boundingRect.x)) : 0 readonly property int _blockW: root._nameFullW + root._gapW + root._tagsFullW readonly property int _scrollDist: Math.max(0, root._blockW - root._avail) @@ -93,6 +98,7 @@ Item { TextMetrics { id: nameMetrics + objectName: "scrollingCaptionNameMetrics" text: root.name font.family: root.fontFamily @@ -101,6 +107,7 @@ Item { TextMetrics { id: tagsMetrics + objectName: "scrollingCaptionTagsMetrics" text: root.tags font.family: root.fontFamily diff --git a/src/ui/components/Tile.qml b/src/ui/components/Tile.qml index bb8ada43..6009980e 100644 --- a/src/ui/components/Tile.qml +++ b/src/ui/components/Tile.qml @@ -24,6 +24,7 @@ // - name: string — model display name (used by the procedural // fallback while the cover PNG decodes) // - coverKey: string — relative path under resources/images/ (no extension) +// - topLabel: string — optional compact label above cover art // - favorite: int — optional 0/1; shows a small heart badge when 1 import QtQuick @@ -73,6 +74,7 @@ Item { readonly property bool delegateIsFocused: parent.isFocused readonly property string delegateName: parent.name readonly property string delegateCoverKey: parent.coverKey + readonly property string delegateTopLabel: parent.topLabel ?? "" readonly property bool delegateFavorite: parent.favorite !== 0 // qmllint disable missing-property compiler readonly property bool delegateHidden: parent.hidden === true @@ -107,6 +109,7 @@ Item { // flashes focused for the frames before restore corrects the index. // Defaults true for hosts that do not wire it. readonly property bool delegateFocusReady: parent.focusReady ?? true + readonly property bool delegateLoadFocusedCover: parent.loadFocusedCover ?? true // qmllint enable missing-property property var layoutProfile: null readonly property var _surfaceProfile: root.layoutProfile && root.layoutProfile.surface ? root.layoutProfile.surface : null @@ -131,6 +134,10 @@ Item { readonly property int _captionHeight: Sizing.pctH(5.5) readonly property int _captionGap: Sizing.pctH(0.4) readonly property int _captionTextSize: Sizing.fontSize(2.2) + readonly property bool _hasTopLabel: root.delegateTopLabel !== "" + readonly property int _topLabelHeight: Sizing.pctH(4.2) + readonly property int _topLabelGap: Sizing.pctH(0.4) + readonly property int _topLabelTextSize: Sizing.fontSize(2) readonly property int _tileCornerRadius: root._surfaceProfile ? root._surfaceProfile.cornerRadius : Sizing.cornerRadius // Width available to the bottom caption. A half-corner-radius inset on each // side keeps glyphs clear of the rounded corners while giving the title a @@ -151,12 +158,9 @@ Item { // chooses the subdirectory; Tile is agnostic. Resources.coverUrl is // the single source of truth for the qrc layout — see Resources.qml. // - // The model's `icons/Loading` sentinel is a special case: it means - // "cover fetch is in flight". Routing it through the full-bleed - // cover slot would rasterise the SVG at the entire icon area; the - // existing `loadingGlyph` overlay below already defines the - // standard centred hourglass size, so swallow the source here and - // let `loadingGlyph` own the painting. + // The model's `icons/Loading` sentinel means "cover fetch is in flight". + // Swallow it so media tiles remain blank until real art is ready; painting + // the sentinel in the cover slot would scale an hourglass across the card. readonly property bool _coverPending: root.delegateCoverKey === "icons/Loading" readonly property bool _systemCover: root.delegateCoverKey.startsWith("systems/") // True for any built-in icon routed through the tinted-svg provider: @@ -170,61 +174,25 @@ Item { // (its key prefix), not on `_isTinted`, so the decode policy stays correct // independently of theme-tint behavior. readonly property bool _coverIsRealArt: root.delegateCoverKey.startsWith("media-image/") || root.delegateCoverKey.startsWith("custom-image/") - // Real raster art still in flight: the key is media/custom art but the - // Image has not reached a terminal state. Paired with _coverPending below - // so the busy state stays continuously true across the model's - // pending-sentinel -> real-key handoff (both derive from delegateCoverKey - // and flip together), which is what keeps the loading cue from blinking. - readonly property bool _coverMediaImagePending: root._coverIsRealArt && coverBase.status !== Image.Ready && coverBase.status !== Image.Error - // Single combined "waiting on a cover" predicate. Mirrors - // BrowseDetailPane._coverBusy — the loading cue is gated on this one value - // so an internal state change mid-wait never resets the debounce. - readonly property bool _coverBusy: root._coverPending || root._coverMediaImagePending || coverBase.status === Image.Loading // Unfocused ramp — always loaded for tinted keys; also the sole source for // real art (media-image/, custom-image/) which is focus-independent. readonly property url _coverBaseSrc: root._coverPending ? "" : Resources.coverUrl(root.delegateCoverKey, Theme.logoPrimary, Theme.logoSecondary, Theme.logoShadow) // Focused ramp — only loaded for tinted icons; empty string for real art so // the Image item never initiates a second fetch for cover/boxart tiles. - readonly property url _coverFocusSrc: (root._isTinted && !root._coverPending) ? Resources.coverUrl(root.delegateCoverKey, Theme.logoFocusPrimary, Theme.logoFocusSecondary, Theme.logoFocusShadow) : "" + readonly property url _coverFocusSrc: (root.delegateLoadFocusedCover && root._isTinted && !root._coverPending) ? Resources.coverUrl(root.delegateCoverKey, Theme.logoFocusPrimary, Theme.logoFocusSecondary, Theme.logoFocusShadow) : "" // True once the focused ramp is decoded and this tile is the focused // selection — used to suppress coverBase so the two renders don't stack // (which would double the effective opacity on hidden tiles). readonly property bool _focusCoverActive: root._focusedSelection && root._isTinted && coverFocus.status === Image.Ready - // Show the procedural name fallback only when the icon genuinely failed - // to load (no such logo), never while it is merely decoding. During the - // Loading/Null window the slot stays blank so the name does not flash in - // before the icon pops in. coverBase always has a real source when not - // _coverPending, so it reliably reaches Ready or Error. - readonly property bool _fallbackVisible: !root.showCaption && !root._coverPending && coverBase.status === Image.Error + // System wordmarks are a terminal-error fallback only. Every logo request + // must first pass through Image.Loading and the provider; never show text + // for Null/Loading, and never substitute text for failed category, icon, or + // media artwork. + readonly property bool _fallbackVisible: root._systemCover && !root.showCaption && !root._coverPending && coverBase.status === Image.Error readonly property int _fallbackTextSize: root._systemCover ? Sizing.fontSize(5.8) : Sizing.fontSize(2.4) readonly property int _fallbackMinimumTextSize: root._systemCover ? Sizing.fontSize(2.8) : Sizing.fontSize(2.4) readonly property bool _startupTraceResource: root.delegateCoverKey.startsWith("categories/") || root.delegateCoverKey === "icons/PlayOutline" || root.delegateCoverKey === "icons/HeartOutline" || root.delegateCoverKey === "icons/History" || root.delegateCoverKey === "icons/Settings" property double _startupTraceLoadStartedAt: 0 - // Loading-cue debounce. A QML Image always passes through Image.Loading for - // ~1 frame before Ready, even when the media-image provider returns a cached - // cover in ~0.1ms, which would flash the hourglass on every cached tile. - // Gated on _coverBusy (not raw Image status) so the cue stays solid across - // the pending->loading handoff; only flips true once a wait outlasts the - // delay, so instant cached covers never show it. Mirrors BrowseDetailPane. - property bool _coverLoadingDelayElapsed: false - - on_CoverBusyChanged: root._updateCoverLoadingDelay() - - Timer { - id: coverLoadingDelayTimer - - interval: 150 - repeat: false - onTriggered: root._coverLoadingDelayElapsed = root._coverBusy - } - - function _updateCoverLoadingDelay(): void { - coverLoadingDelayTimer.stop(); - root._coverLoadingDelayElapsed = false; - if (!root._coverBusy) - return; - coverLoadingDelayTimer.restart(); - } anchors.fill: parent // One-shot push-in scale, shared by every button-like action. The @@ -423,8 +391,27 @@ Item { // // `_focusCoverActive` suppresses coverBase when the focused ramp is on top, // preventing the two opaque layers from stacking their alpha on hidden tiles. + Text { + objectName: "tileTopLabel" + x: root._captionSideInset + y: root._padding + width: root._captionTextMaxWidth + height: root._topLabelHeight + visible: root._hasTopLabel + text: root.delegateTopLabel + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.family: Theme.fontUi + font.pixelSize: root._topLabelTextSize + font.weight: Font.Medium + color: root._focusedSelection ? Theme.textPrimary : Theme.textLabel + renderType: Text.NativeRendering + } + Image { id: coverBase + objectName: "tileCoverBase" width: parent.width - 2 * root._padding source: root._coverBaseSrc @@ -447,14 +434,40 @@ Item { fillMode: Image.PreserveAspectFit smooth: true asynchronous: true - // Hide when the focused ramp is fully decoded and showing on top; the - // normal hidden-item dim (0.4) is still applied so the two renders are - // never stacked at the same opacity simultaneously. - opacity: (coverBase.status === Image.Ready && !root._focusCoverActive) ? (root.delegateHidden ? 0.4 : 1.0) : 0 + // Real media covers get one brief reveal after decode. Tinted system, + // category, and action artwork remains instant. Keeping this multiplier + // separate prevents focus-ramp swaps and hidden-state dimming from + // accidentally becoming opacity animations. + property real revealOpacity: root._coverIsRealArt ? 0 : 1 + opacity: (coverBase.status === Image.Ready && !root._focusCoverActive) ? coverBase.revealOpacity * (root.delegateHidden ? 0.4 : 1.0) : 0 + + NumberAnimation { + id: coverRevealAnimation + objectName: "tileCoverRevealAnimation" + + target: coverBase + property: "revealOpacity" + from: 0 + to: 1 + duration: Motion.dur(Motion.pressMs) + easing.type: Easing.OutQuad + } + + function updateReveal(): void { + coverRevealAnimation.stop(); + if (coverBase.status === Image.Ready && root._coverIsRealArt) { + coverBase.revealOpacity = 0; + coverRevealAnimation.restart(); + } else { + coverBase.revealOpacity = coverBase.status === Image.Ready ? 1 : 0; + } + } + + Component.onCompleted: coverBase.updateReveal() anchors { top: parent.top - topMargin: root._padding + topMargin: root._padding + (root._hasTopLabel ? root._topLabelHeight + root._topLabelGap : 0) bottom: parent.bottom // In caption mode the cover sits above the bottom caption strip with // only `_captionGap` of breathing room. The caption is flush against @@ -465,6 +478,7 @@ Item { } onStatusChanged: { + coverBase.updateReveal(); if (!root._startupTraceResource) return; if (status === Image.Loading) { @@ -502,41 +516,13 @@ Item { anchors { top: parent.top - topMargin: root._padding + topMargin: root._padding + (root._hasTopLabel ? root._topLabelHeight + root._topLabelGap : 0) bottom: parent.bottom bottomMargin: root.showCaption ? root._captionHeight + root._captionGap : root._padding horizontalCenter: parent.horizontalCenter } } - // Caption-mode loading cue. Centred hourglass glyph that paints - // only during the Image.Loading window — once the cover lands the - // glyph hides and the cover paints in. Error/Null cover state - // also hides the glyph (a stuck hourglass on a permanently failed - // cover would mislead) and the bottom caption still identifies - // the tile. Bundled qrc asset, decode is cheap, no animation. - Image { - id: loadingGlyph - - x: coverBase.x + Sizing.center(coverBase.width, width) - y: coverBase.y + Sizing.center(coverBase.height, height) - width: Sizing.pctH(10) - height: Sizing.pctH(10) - source: Resources.iconUrl("Loading") - // Loading.svg has a 24×24 native viewBox; without sourceSize - // Qt rasterises at that intrinsic size and bilinear-upscales - // to the rendered box, which reads as soft on every screen - // taller than ~240 px. Pinning sourceSize to the rendered - // dimensions makes the SVG renderer rasterise at target size - // — same pattern StatusIcon.qml and LoadingIndicator.qml use. - sourceSize.width: Sizing.px(width) - sourceSize.height: Sizing.px(height) - fillMode: Image.PreserveAspectFit - smooth: true - asynchronous: false - visible: root.showCaption && root._coverBusy && root._coverLoadingDelayElapsed - } - Image { id: favoriteGlyph @@ -580,6 +566,7 @@ Item { // hourglass above signals load progress, so a wrapping copy of the name // in this slot is redundant. Text { + objectName: "tileFallbackText" anchors.fill: coverBase anchors.margins: root._systemCover ? Sizing.pctH(1) : 0 text: root.delegateName diff --git a/src/ui/components/TileLoader.qml b/src/ui/components/TileLoader.qml index 3488c483..dda70497 100644 --- a/src/ui/components/TileLoader.qml +++ b/src/ui/components/TileLoader.qml @@ -18,6 +18,9 @@ Loader { required property bool isFocused required property string name required property string coverKey + // Optional compact label rendered above tile art. Mixed-system media views + // use it for the system name; all other grids leave it empty. + property string topLabel: "" property int favorite: 0 property bool hidden: false // Newline-joined disambiguating-tag tokens (region, disc, rev, ...). @@ -46,4 +49,9 @@ Loader { // never paints a ring before the real selection lands. Default true so // hosts that do not wire it focus normally. property bool focusReady: true + // Controls whether Tile instantiates its tinted focused-ramp Image source. + // Default true preserves existing Hub/media behavior; large system grids + // can restrict this to the selected tile to avoid cold-rendering one extra + // SVG for every hidden delegate. + property bool loadFocusedCover: true } diff --git a/src/ui/components/TopStatusStrip.qml b/src/ui/components/TopStatusStrip.qml index ec19150c..37634063 100644 --- a/src/ui/components/TopStatusStrip.qml +++ b/src/ui/components/TopStatusStrip.qml @@ -10,7 +10,8 @@ // Slots: // left — total-count badge (visible when `totalText !== ""`) // center — screen title (category / system name) -// right — "Page N / M" counter (visible when `totalPages > 1`) +// right — "Page N / M" for bounded results, or "Page N" when final +// page count is unknown import QtQuick import Zaparoo.Theme @@ -22,8 +23,13 @@ Item { property string title: "" property int currentPage: 0 // 0-indexed; displayed as N+1 property int totalPages: 1 + // False for cursor chains whose final page is unknown until exhaustion. + // Such screens show only "Page N" rather than a denominator that grows as + // more rows arrive. + property bool pageTotalKnown: true property string totalText: "" // formatted; empty hides the slot - property string rightTextOverride: "" // formatted; non-empty replaces Page N / M + property string rightTextOverride: "" // formatted; non-empty replaces page text + readonly property string pageText: status.rightTextOverride !== "" ? status.rightTextOverride : (status.pageTotalKnown ? qsTr("Page %1 / %2").arg(status.currentPage + 1).arg(status.totalPages) : qsTr("Page %1").arg(status.currentPage + 1)) property int slotMargin: Sizing.pctW(5) readonly property int _slotWidth: Sizing.px(status.width / 3) readonly property int _textMeasureSlack: Theme.crtNativePath ? 0 : 2 @@ -81,14 +87,14 @@ Item { Text { id: pageCounter - visible: status.rightTextOverride !== "" || status.totalPages > 1 + visible: status.rightTextOverride !== "" || !status.pageTotalKnown || status.totalPages > 1 anchors.right: parent.right anchors.rightMargin: status.slotMargin anchors.bottom: titleText.bottom width: status._slotWidth - status.slotMargin elide: Text.ElideRight horizontalAlignment: Text.AlignRight - text: status.rightTextOverride !== "" ? status.rightTextOverride : qsTr("Page %1 / %2").arg(status.currentPage + 1).arg(status.totalPages) + text: status.pageText font.family: Theme.fontUi font.pixelSize: Sizing.fontSize(2.9) color: Theme.textPrimary diff --git a/src/ui/screens/FavoriteSystemsScreen.qml b/src/ui/screens/FavoriteSystemsScreen.qml index fd6e0485..2c19b956 100644 --- a/src/ui/screens/FavoriteSystemsScreen.qml +++ b/src/ui/screens/FavoriteSystemsScreen.qml @@ -40,6 +40,7 @@ MediaListScreen { } gridColumnsOverride: Sizing.systemsGridShape(Sizing.screenWidth, Sizing.screenHeight).columns gridRowsOverride: Sizing.systemsGridShape(Sizing.screenWidth, Sizing.screenHeight).rows + gridShowCaption: false emptyText: qsTr("No favorites yet") loadingText: qsTr("Loading favorite systems…") detailShowTitle: false @@ -49,11 +50,25 @@ MediaListScreen { retryAction: () => Browse.FavoriteSystemsModel.retry() acceptAction: index => { - if (favoriteSystems.mediaModel === null) + if (favoriteSystems.mediaModel === null || favoriteSystems.mediaGrid.itemCount <= 0 || pressCommit.running) return; - if (favoriteSystems.mediaGrid.itemCount <= 0) - return; - const systemId = Browse.FavoriteSystemsModel.system_id_at(index); - favoriteSystems.requestAccept(systemId); + favoriteSystems.pulseActivate(); + pressCommit._systemId = Browse.FavoriteSystemsModel.system_id_at(index); + pressCommit.arm(); + } + cancelAction: () => { + pressCommit.stop(); + favoriteSystems.requestHubScreen(); + } + + DeferredAction { + id: pressCommit + + property string _systemId: "" + onDeferred: { + const systemId = _systemId; + _systemId = ""; + favoriteSystems.requestAccept(systemId); + } } } diff --git a/src/ui/screens/FavoritesScreen.qml b/src/ui/screens/FavoritesScreen.qml index a5b29889..2ce5c4af 100644 --- a/src/ui/screens/FavoritesScreen.qml +++ b/src/ui/screens/FavoritesScreen.qml @@ -35,6 +35,8 @@ MediaListScreen { totalItemsOverride: favorites.favoriteTotal > 0 ? favorites.favoriteTotal : -1 gridTotalItemsOverride: favorites.favoriteTotal > 0 ? favorites.favoriteTotal : -1 gridHasMorePages: Browse.FavoritesModel.has_next_page + paginationTotalKnown: false + gridTileTopLabelProvider: favorites.selectedSystemId === "" ? (index => Browse.FavoritesModel.system_name_at(index)) : null topStripTotalPagesProvider: () => favorites.mediaGrid.totalPageCount topStripTotalTextProvider: () => favorites.favoriteTotal >= 0 ? qsTr("%n favorite(s)", "", favorites.favoriteTotal) : "" pageMenuEnabledWhenEmpty: true diff --git a/src/ui/screens/GamesScreen.qml b/src/ui/screens/GamesScreen.qml index c54885e0..8c50f118 100644 --- a/src/ui/screens/GamesScreen.qml +++ b/src/ui/screens/GamesScreen.qml @@ -23,6 +23,9 @@ MediaListScreen { id: games property alias gamesGrid: games.mediaGrid + // Main.qml reads this when folder request signal arrives. Captured before + // synchronous persistence so telemetry includes full perceived button time. + property double lastNavigationInputAt: 0 // Seed persisted server-side scope before first system browse. Component.onCompleted: { @@ -103,6 +106,7 @@ MediaListScreen { return; const entryType = Browse.GamesModel.entry_type_at(index); if ((entryType === "directory" || entryType === "root") && !Browse.GamesModel.is_media_capable_at(index)) { + games.lastNavigationInputAt = Date.now(); // Persist synchronously (MiSTer may be killed at any time), then // play the cue and defer the navigation signal so the push-in // completes on a static scene before the model reload starts. @@ -126,6 +130,8 @@ MediaListScreen { // Disarm any pending accept so a press-then-back inside the deferred // window cannot launch/navigate after the user has backed out. pressCommit.stop(); + if (games._atFolderLevel()) + games.lastNavigationInputAt = Date.now(); games.flushSelectedPersist(); if (games._atFolderLevel()) games.requestNavigateOutOfFolder(); @@ -134,6 +140,9 @@ MediaListScreen { } showTopStrip: games._statusProfile ? games._statusProfile.topStripVisible : true topStripTitleProvider: () => { + const folderName = games._folderNameForPath(Browse.GamesModel.current_path); + if (folderName !== "") + return folderName; const sid = Browse.GamesModel.current_system_id; if (sid === "") return ""; @@ -154,17 +163,25 @@ MediaListScreen { return qsTr("%1 / %2").arg(games.gamesGrid.currentIndex + 1).arg(total); } gridBottomMargin: games._footerProfile ? games._footerProfile.gridBottomMargin : (Sizing.pctH(8) + Sizing.pctH(7)) + + function _folderNameForPath(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + if (trimmed === "") + return ""; + const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return trimmed.substring(separator + 1); + } gridColumnsOverride: games._gridColumns gridRowsOverride: games._gridRows gridTotalItemsOverride: Browse.GamesModel.total_dirs + Browse.GamesModel.total_files gridHasMorePages: Browse.GamesModel.has_next_page - gridLoadMoreAction: urgent => { - // A letter jump bulk-loads to the target in one shot (overlay is up); - // page-wrap targets and fast-scroll stay on the rapid trickle, ordinary - // prefetch on the gentle one. + gridLoadMoreAction: _urgent => { + // Letter jumps bulk-load to their target and held rapid scrolling uses + // larger chunks. A pending ordinary page turn is urgent only because + // the user is waiting; one visual page is sufficient to satisfy it. if (games.gamesGrid.hasPendingJump) Browse.GamesModel.fetch_more_jump(games.gamesGrid.pendingJumpIndex); - else if (urgent || games.detailRapidScrollActive) + else if (games.detailRapidScrollActive) Browse.GamesModel.fetch_more_rapid(); else Browse.GamesModel.fetch_more(); diff --git a/src/ui/screens/HubScreen.qml b/src/ui/screens/HubScreen.qml index 53f97c5c..00f25dea 100644 --- a/src/ui/screens/HubScreen.qml +++ b/src/ui/screens/HubScreen.qml @@ -184,7 +184,8 @@ Item { readonly property bool resumeKnownUnavailable: hub.resumeModelEnabled && !Browse.RecentsModel.resume_loading && !Browse.RecentsModel.resume_available && Browse.AppStatus.connection_state === 2 readonly property bool resumeActionVisible: !hub.resumeKnownUnavailable - readonly property string _emptyCatalogFallbackAction: Browse.BuildInfo.update_enabled ? "update" : "settings" + readonly property bool _internetAvailable: Browse.SystemStatus.has_wifi_internet || Browse.SystemStatus.has_lan_internet + readonly property string _emptyCatalogFallbackAction: Browse.BuildInfo.update_enabled && hub._internetAvailable ? "update" : "settings" // Action-row data. Resume is visible by default while Core history // is unknown; hide it only after Recents proves there is nothing @@ -213,7 +214,7 @@ Item { enabled: true, text: qsTr("Recently Played") }); - if (Browse.BuildInfo.update_enabled) { + if (Browse.BuildInfo.update_enabled && hub._internetAvailable) { entries.push({ id: "update", coverKey: hub._hubCoverKey("update", "icons/RefreshCw"), @@ -771,8 +772,9 @@ Item { } // Active label — single big line under the bottom row, swaps text - // on every move. Reads from whichever row owns focus. Hidden during - // a forward transition, mirroring the rows. + // on every move. Reads from whichever row owns focus. Keep it visible + // while tiles hide for a forward transition so source context remains + // stable until the destination cut. ActiveLabel { id: activeLabel @@ -795,7 +797,7 @@ Item { return entry.name; return ""; } - visible: !hub.transitioning + visible: true } // CategoriesModel has no `loading` qproperty — the catalog is diff --git a/src/ui/screens/MediaListScreen.qml b/src/ui/screens/MediaListScreen.qml index 3eb5e2d9..63055120 100644 --- a/src/ui/screens/MediaListScreen.qml +++ b/src/ui/screens/MediaListScreen.qml @@ -71,6 +71,9 @@ Item { property var gridCurrentPageChangedAction: null property var gridCurrentIndexChangedAction: null property var gridLoadMoreAction: null + // Optional per-row label above cover art. Used only by mixed-system flat + // views (Favorites/Recents); Games leaves it null. + property var gridTileTopLabelProvider: null property string gridViewId: "gamesGrid" property string listViewId: "gamesList" property string tateListViewId: "gamesListTate" @@ -82,6 +85,10 @@ Item { property bool transitioning: false property bool active: true + // Router can suppress grid Image sources until one model/card frame has + // painted. Defaults true for Favorites/Recents; Games drives it around + // screen and folder navigation. + property bool coverRevealReady: true property bool gridFocused: true property bool optimisticLoading: false // True while a jump-to-letter walk is loading the intervening pages. Folded @@ -126,13 +133,25 @@ Item { readonly property var _gridViewportShape: Sizing.gamesGridShape(root._gridViewportWidth, root._gridViewportHeight) property int gridColumnsOverride: root._gridViewportShape.columns property int gridRowsOverride: root._gridViewportShape.rows + // Media grids label every cover inside its tile. System grids opt out so + // curated logos remain image-only and the focused name lives in ActiveLabel, + // matching SystemsScreen. + property bool gridShowCaption: true property bool pageLoadingVisible: false property string bottomStatusLeftText: "" property string bottomStatusRightText: "" property int gridTotalItemsOverride: -1 property bool gridHasMorePages: false + // False for cursor-based queries that cannot know their final page until + // the cursor is exhausted. Hides growing denominators/scroll thumbs while + // retaining current-page text and directional arrows. + property bool paginationTotalKnown: true readonly property bool _listRapidLineMove: root._listLayout && (root.detailRapidScrollAction === "up" || root.detailRapidScrollAction === "down") readonly property bool _showRapidScrollIndicator: root.detailRapidIndicatorActive && !root._listRapidLineMove + readonly property bool _rapidSnapshotVisible: !root._gateHide && root._showRapidScrollIndicator && root._rapidSnapshotReady && mediaGrid.itemCount > 0 && !root._listLayout + property var _rapidSnapshotResult: null + property bool _rapidSnapshotReady: false + property int _rapidSnapshotGeneration: 0 readonly property bool _listLayout: root.forceListLayout || Browse.Settings.current_browse_layout === "list" readonly property bool _tateListLayout: root._listLayout && Browse.Settings.current_orientation !== "horizontal" readonly property string _activeListViewId: root._tateListLayout ? root.tateListViewId : root.listViewId @@ -158,6 +177,15 @@ Item { // is in a usable state. signal requestPageMenu + Connections { + target: root.mediaModel + + function onLoadingChanged(): void { + if (root.mediaModel && root.mediaModel.loading) + mediaGrid.prepareForModelReplacement(); + } + } + on_ListLayoutChanged: { if (!root._listLayout) return; @@ -170,6 +198,24 @@ Item { // PagedGrid tile; in list layout it increments the BrowseListDetailView // pulse so the selected row fires its push-in. The same push-in cue // serves both forward navigation and game launch. + // Capture during the d-pad hold delay, before rapid mode suspends live tile + // delegates. Retaining the grab result keeps its itemgrabber URL alive. + // Generation check drops a late callback after direction/layout changes. + function prepareRapidSnapshot(): void { + root._rapidSnapshotGeneration++; + const generation = root._rapidSnapshotGeneration; + root._rapidSnapshotReady = false; + root._rapidSnapshotResult = null; + if (root._listLayout || !mediaGrid.visible || mediaGrid.itemCount <= 0) + return; + mediaGrid.grabToImage(function (result) { + if (generation !== root._rapidSnapshotGeneration) + return; + root._rapidSnapshotResult = result; + root._rapidSnapshotReady = true; + }, Qt.size(mediaGrid.width, mediaGrid.height)); + } + function pulseActivate(): void { if (root._listLayout) listCard.activatePulse++; @@ -463,8 +509,9 @@ Item { title: typeof root.topStripTitleProvider === "function" ? root.topStripTitleProvider() : root.screenTitle currentPage: typeof root.topStripCurrentPageProvider === "function" ? root.topStripCurrentPageProvider() : mediaGrid.currentPage totalPages: typeof root.topStripTotalPagesProvider === "function" ? root.topStripTotalPagesProvider() : Math.max(1, Math.ceil(root._count() / mediaGrid.pageSize)) + pageTotalKnown: root.paginationTotalKnown totalText: typeof root.topStripTotalTextProvider === "function" ? root.topStripTotalTextProvider() : (root._listLayout ? "" : (root._count() > 0 ? qsTr("%1 entries").arg(root._count()) : "")) - rightTextOverride: typeof root.topStripRightTextProvider === "function" ? root.topStripRightTextProvider() : (!root._listLayout || mediaGrid.itemCount <= 0 ? "" : qsTr("%1 / %2").arg(mediaGrid.currentIndex + 1).arg(Math.max(1, root._count()))) + rightTextOverride: typeof root.topStripRightTextProvider === "function" ? root.topStripRightTextProvider() : (!root.paginationTotalKnown || !root._listLayout || mediaGrid.itemCount <= 0 ? "" : qsTr("%1 / %2").arg(mediaGrid.currentIndex + 1).arg(Math.max(1, root._count()))) } BrowseListDetailView { @@ -486,7 +533,10 @@ Item { currentIndex: mediaGrid.currentIndex focusReady: root._focusReady detailTitle: listCard.currentName - detailCoverKey: root.detailRapidScrollActive ? root.detailPlaceholderKey : (root._detailImageKey() !== "" ? root._detailImageKey() : listCard.currentCoverKey) + // Detail pane is not painted in grid layout. Withholding its source + // avoids a hidden width-constrained decode competing with visible + // height-constrained tile covers. + detailCoverKey: !root._listLayout ? "" : (root.detailRapidScrollActive ? root.detailPlaceholderKey : (root._detailImageKey() !== "" ? root._detailImageKey() : listCard.currentCoverKey)) detailShowDescription: root.detailShowDescription detailShowTitle: root.detailShowTitle detailTags: root._detailTags() @@ -526,7 +576,7 @@ Item { model: root.mediaModel delegate: Tile { layoutProfile: root._gridLayoutProfile - showCaption: true + showCaption: root.gridShowCaption coverSourceSize: Sizing.gamesGridCoverSourceSize(root._gridViewportWidth, root._gridViewportHeight) } layoutProfile: root._gridLayoutProfile @@ -534,8 +584,12 @@ Item { rowsOverride: root.gridRowsOverride totalItemsOverride: root.gridTotalItemsOverride hasMorePages: root.gridHasMorePages + paginationTotalKnown: root.paginationTotalKnown + tileTopLabelProvider: root.gridTileTopLabelProvider + coverRequestsEnabled: root.coverRevealReady coverLoadingPaused: root.detailRapidScrollActive - rapidRenderMode: root.detailRapidScrollActive + cellsVisible: !root._rapidSnapshotVisible + rapidRenderMode: root.detailRapidScrollActive && root._rapidSnapshotReady onLoadMoreRequested: urgent => { if (typeof root.gridLoadMoreAction === "function") root.gridLoadMoreAction(urgent); @@ -566,7 +620,7 @@ Item { ActiveLabel { id: activeLabel - visible: !root._gateHide && !root._listLayout && root.renderGridLayout + visible: !root._loading() && !root._overlayLoadingVisible && !root._listLayout && root.renderGridLayout anchors.left: parent.left anchors.right: parent.right anchors.top: root.activeLabelAtBottom ? undefined : mediaGrid.bottom @@ -622,6 +676,36 @@ Item { anchors.verticalCenter: activeLabel.verticalCenter } + // Frozen rapid-navigation presentation. The live grid is hidden while this + // is visible, so the dim Image blends once against an opaque black backing + // rather than compositing over moving delegates on every repeat tick. + Item { + id: rapidSnapshot + objectName: "rapidScrollSnapshot" + visible: root._rapidSnapshotVisible + x: mediaGrid.x + mediaGrid.leftInset + y: mediaGrid.y + mediaGrid.topInset + width: mediaGrid._contentWidth + height: mediaGrid.rows * mediaGrid.cellHeight + Math.max(0, mediaGrid.rows - 1) * mediaGrid.cellSpacingY + clip: true + z: 19 + + Rectangle { + anchors.fill: parent + color: Theme.bgBar + } + + Image { + objectName: "rapidScrollSnapshotImage" + anchors.fill: parent + source: root._rapidSnapshotResult ? root._rapidSnapshotResult.url : "" + sourceClipRect: Qt.rect(mediaGrid.leftInset, mediaGrid.topInset, rapidSnapshot.width, rapidSnapshot.height) + fillMode: Image.Stretch + smooth: false + opacity: 0.28 + } + } + RapidScrollIndicator { visible: !root._gateHide && root._showRapidScrollIndicator && mediaGrid.itemCount > 0 && !root._listLayout x: Sizing.center(parent.width, width) diff --git a/src/ui/screens/RecentsScreen.qml b/src/ui/screens/RecentsScreen.qml index db1fe137..f068de19 100644 --- a/src/ui/screens/RecentsScreen.qml +++ b/src/ui/screens/RecentsScreen.qml @@ -25,4 +25,7 @@ MediaListScreen { emptyText: qsTr("Nothing played yet") loadingText: qsTr("Loading recently played…") detailShowTitle: false + gridHasMorePages: Browse.RecentsModel.has_next_page + paginationTotalKnown: false + gridTileTopLabelProvider: index => Browse.RecentsModel.system_name_at(index) } diff --git a/src/ui/screens/SettingsScreen.qml b/src/ui/screens/SettingsScreen.qml index 9f5cd5aa..33c3e132 100644 --- a/src/ui/screens/SettingsScreen.qml +++ b/src/ui/screens/SettingsScreen.qml @@ -9,15 +9,14 @@ import Zaparoo.Ui import Zaparoo.Browse as Browse // cxx-qt 0.8 patches `isFinal: true` on singleton properties but the -// qmltypes schema has no `isFinal` slot for Method, so every qinvokable -// call on a Zaparoo.Browse singleton (set_resolution) still trips -// qmllint's "Member can be shadowed" check. Until the schema grows -// method-level finality, suppress the compiler category file-wide. +// qmltypes schema has no `isFinal` slot for Method, so qinvokable calls on +// Zaparoo.Browse singletons still trip qmllint's "Member can be shadowed" +// check. Until the schema grows method-level finality, suppress the compiler +// category file-wide. // qmllint disable compiler -// Settings screen — gamepad-driven vertical form. Resolution is MiSTer-only -// because it changes frontend startup video config and applies on restart. -// Button style is cross-platform and selects the resource directory for +// Settings screen — gamepad-driven vertical form. Button style is +// cross-platform and selects the resource directory for // help-bar button glyphs (Style A/B/C/D → resources/images/buttons/{a,b,c,d}/). // Mouse support is cross-platform and controls cursor visibility plus mouse // hit targets. @@ -114,17 +113,9 @@ Item { coverKey: "icons/Support" } ] - // Display = video output only. Resolution is MiSTer-only (changes startup - // video config, applies on restart). + // Display = video output and presentation controls. readonly property var displayInterfaceFields: { const out = []; - if (Browse.Settings.is_mister) { - out.push({ - kind: "field", - id: "resolution", - label: qsTr("Resolution") - }); - } out.push({ kind: "field", id: "orientation", @@ -398,8 +389,6 @@ Item { } function _fieldValue(id: string): string { - if (id === "resolution") - return settings._resolutionDisplay(Browse.Settings.current_resolution); if (id === "language") return settings._languageDisplay(Browse.Settings.current_language); if (id === "orientation") @@ -548,7 +537,7 @@ Item { if (!settings._isField(settings.currentIndex)) return false; const id = settings.fields[settings.currentIndex].id; - return id === "language" || id === "clockFormat" || id === "region" || id === "orientation" || id === "browseLayout" || id === "systemLogoStyle" || id === "buttonLayout" || id === "resolution" || id === "screensaverTimeout" || id === "mediaImageType" || id === "crtVideoStandard"; + return id === "language" || id === "clockFormat" || id === "region" || id === "orientation" || id === "browseLayout" || id === "systemLogoStyle" || id === "buttonLayout" || id === "screensaverTimeout" || id === "mediaImageType" || id === "crtVideoStandard"; } // True when focused row accepts A without left/right cycling: // pickers, jobs, modal/navigation rows, and root category rows. @@ -613,19 +602,6 @@ Item { return idx >= 0 ? idx : 0; } - function _resolutionList(): list { - const raw = Browse.Settings.available_resolutions; - return raw === undefined || raw === null ? [] : raw; - } - - function _resolutionDisplay(value: string): string { - // Empty resolution means "fall back to frontend.toml defaults", - // which the Settings model treats as the platform default. Render - // it as a translated label rather than an empty cell so the user - // sees something selectable. - return value === "" ? qsTr("Default") : value; - } - function _buttonLayoutList(): list { const raw = Browse.Settings.available_button_layouts; return raw === undefined || raw === null ? [] : raw; @@ -831,16 +807,7 @@ Item { let title = ""; let entries = []; let initialId = ""; - if (id === "resolution") { - title = qsTr("Resolution"); - const list = settings._resolutionList(); - for (let i = 0; i < list.length; i++) - entries.push({ - id: list[i], - label: settings._resolutionDisplay(list[i]) - }); - initialId = Browse.Settings.current_resolution; - } else if (id === "language") { + if (id === "language") { title = qsTr("Language"); const list = settings._languageList(); for (let i = 0; i < list.length; i++) diff --git a/src/ui/screens/SystemsScreen.qml b/src/ui/screens/SystemsScreen.qml index cecf4a5a..524d271d 100644 --- a/src/ui/screens/SystemsScreen.qml +++ b/src/ui/screens/SystemsScreen.qml @@ -30,10 +30,17 @@ Item { property alias systemsGrid: systemsGrid property alias listCard: listCard property bool transitioning: false + // True while Hub→Systems routing is preparing this destination, before + // the delayed loading cue becomes visible. Used only to suspend hidden + // delegates; source-screen hiding still follows `transitioning`. + property bool preparingTransition: false // Set false by MainLayout when this screen is not the active screen. // Forwarded to systemsGrid.screenSettling so tile delegates reset // their push-in scale off-screen. property bool active: true + // False for the first destination frame so layout/text can paint before + // current-page SVG decoding begins. Router enables it after frame swap. + property bool coverRevealReady: true // Router-driven flag: `MainLayout` writes this to // `!ScreenManager.hasModal` so the focused tile's accent ring // hides while a modal (the context menu) is on top of the stack. @@ -294,6 +301,16 @@ Item { focused: systems.gridFocused screenSettling: !systems.active focusReady: systems._focusReady + // Keep the lightweight delegate/cursor structure during category + // replacement, but withhold Image sources while hidden. Fully removing + // Repeater's model tears down the prior category synchronously and made + // the transition itself wait on that cleanup. + suspendDelegates: systems._listLayout + coverRequestsEnabled: systems.active && systems.coverRevealReady && !systems.preparingTransition && !systems._gateHide + // Router already warms visible page. Do not simultaneously rasterize + // hidden next-page logos or focused variants for every system. + coverLookaheadPages: 0 + eagerFocusedCovers: false model: Browse.SystemsModel layoutProfile: systems._viewProfile columnsOverride: systems._gridShape.columns @@ -331,7 +348,7 @@ Item { anchors.bottomMargin: systems._footerProfile ? systems._footerProfile.activeLabelBottomMargin : Sizing.pctH(8) height: systems._footerProfile ? systems._footerProfile.activeLabelHeight : Sizing.pctH(7) text: systemsGrid.itemCount > 0 ? Browse.SystemsModel.system_name_at(systemsGrid.currentIndex) : "" - visible: !systems._gateHide && !systems._listLayout + visible: !systems._loading && !systems._overlayLoadingVisible && !systems._listLayout } Text { diff --git a/src/ui/theme/Resources.qml b/src/ui/theme/Resources.qml index 3841435a..bcb00197 100644 --- a/src/ui/theme/Resources.qml +++ b/src/ui/theme/Resources.qml @@ -43,7 +43,6 @@ QtObject { // logos into the restored full-color PNG set when a matching asset exists. property string systemLogoStyle: "tinted" readonly property var _coloredSystemStems: ["3DO", "3DS", "AcornElectron", "AdventureVision", "Amiga", "Amiga1200", "Amiga500", "AmigaCD32", "Amstrad", "Android", "AppleII", "Aquarius", "Arcade", "Arcadia", "Archimedes", "Astrocade", "Atari2600", "Atari5200", "Atari7800", "Atari800", "AtariLynx", "AtariST", "AtariXEGS", "Atomiswave", "BBCMicro", "C16", "C64", "CDI", "CPS1", "CPS2", "CPS3", "CasioPV1000", "ChannelF", "ColecoAdam", "ColecoVision", "CreatiVision", "DAPHNE", "DOS", "Dreamcast", "FDS", "FM7", "FMTowns", "GBA", "GBA2P", "Gaelco", "Gamate", "GameCom", "GameCube", "GameGear", "GameMaster", "GameNWatch", "Gameboy", "Gameboy2P", "GameboyColor", "Genesis", "Genesis.eu", "Genesis.jp", "GenesisMSU", "Hikaru", "Intellivision", "Jaguar", "JaguarCD", "Lynx48", "MSX", "MSX1", "MSX2", "MSX2Plus", "MacOS", "MasterSystem", "MasterSystem.jp", "MegaCD", "MegaCD.us", "MegaDuck", "Model1", "Model2", "Model3", "NAOMI", "NAOMI2", "NDS", "NES", "NES.jp", "NGage", "Namco22", "NeoGeo", "NeoGeoAES", "NeoGeoCD", "NeoGeoMVS", "NeoGeoPocket", "NeoGeoPocketColor", "Nintendo64", "Odyssey2", "Oric", "PC88", "PC98", "PCFX", "PET2001", "PS2", "PS3", "PS4", "PS5", "PSP", "PSX", "Pico8", "PokemonMini", "SAMCoupe", "SG1000", "SGBMSU1", "SNES", "SNES.jp", "SNESMSU1", "Saturn", "ScummVM", "Sega32X", "Sega32X.jp", "SeriesXS", "Singe", "SordM5", "Spectravideo", "Sufami", "SuperACan", "SuperGameboy", "SuperGrafx", "SuperVision", "Switch", "TI994A", "TIC80", "TRS80", "Thomson", "TomyTutor", "Triforce", "TurboGrafx16", "TurboGrafx16.eu", "TurboGrafx16.jp", "TurboGrafx16CD", "TurboGrafx16CD.eu", "TurboGrafx16CD.jp", "VIC20", "Vectrex", "VideopacPlus", "VirtualBoy", "Vita", "Wii", "WiiU", "Windows", "WonderSwan", "WonderSwanColor", "X1", "X68000", "Xbox", "Xbox360", "XboxOne", "ZX81", "ZXSpectrum", "iOS"] - // Empty key returns an empty URL so the caller can use it as a // "no cover" sentinel. function _colorToken(colorValue: var): string { diff --git a/src/ui/theme/Sizing.qml b/src/ui/theme/Sizing.qml index 55c5883a..d5f4ae21 100644 --- a/src/ui/theme/Sizing.qml +++ b/src/ui/theme/Sizing.qml @@ -42,7 +42,10 @@ QtObject { // rotating the scene changes how many tiles fit without stretching // the cards into a different shape. readonly property var _gamesGridConfig: _gridConfig(_browseGridBaseConfig, { - "minCellHeight": crtNativePath ? 96 : 210, + // A 1080p MiSTer output renders through a 960x540 framebuffer. Its + // content viewport is about 365px tall, so 170px preserves the normal + // five-column, two-row page instead of falling back to 2x2. + "minCellHeight": crtNativePath ? 96 : 170, "targetAspect": crtNativePath ? 0.78 : 0.71 }) readonly property var _gamesGridShape: gamesGridShape(screenWidth, screenHeight) diff --git a/src/ui/translations/frontend_ar.ts b/src/ui/translations/frontend_ar.ts index 150c2e68..212d7050 100644 --- a/src/ui/translations/frontend_ar.ts +++ b/src/ui/translations/frontend_ar.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected غير متصل - + Reconnecting… جارٍ إعادة الاتصال… - + Connecting… جارٍ الاتصال… - + Core error خطأ في النواة - + Paused %1/%2 - + Paused متوقف مؤقتًا - + Opt… - + Optimizing جارٍ التحسين - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried فهرسة %1/%2 - %3 - + Indexing %1/%2 فهرسة %1/%2 - + Idx %1/%2 - + Indexing… جارٍ الفهرسة… @@ -324,17 +324,17 @@ Français - Wilfried استخراج %1/%2 - %3 - + Scraping %1/%2 استخراج %1/%2 - + Scr %1/%2 - + Scraping… جارٍ الاستخراج… @@ -399,12 +399,12 @@ Français - Wilfried - + No favorites yet لا توجد مفضلات بعد - + Loading favorite systems… @@ -427,7 +427,7 @@ Français - Wilfried جارٍ تحميل المفضلة… - + %n favorite(s) @@ -446,54 +446,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - إعداد أول مرة + إعداد أول مرة - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - Optimizing database - almost done - جارٍ تحسين قاعدة البيانات - أوشك على الانتهاء + جارٍ تحسين قاعدة البيانات - أوشك على الانتهاء - Indexing paused - تم إيقاف الفهرسة مؤقتًا + تم إيقاف الفهرسة مؤقتًا - Step %1 of %2 - %3 - الخطوة %1 من %2 - %3 + الخطوة %1 من %2 - %3 - Step %1 of %2 - الخطوة %1 من %2 + الخطوة %1 من %2 - Preparing… - جارٍ التحضير… + جارٍ التحضير… - Done. %1 files indexed. - تم. تمت فهرسة %1 ملفات. + تم. تمت فهرسة %1 ملفات. - Cancel - إلغاء + إلغاء - Start scan - ابدأ الفحص + ابدأ الفحص @@ -512,34 +498,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 ملفات - + Loading game… - - + + %1 / %2 - + No games in this system لا توجد ألعاب في هذا النظام - + Loading games… جارٍ تحميل الألعاب… - + Loading more… جارٍ تحميل المزيد… @@ -572,27 +558,27 @@ Français - Wilfried - + Resume - + Favorites المفضلة - + Recently Played تم لعبها مؤخرًا - + Update - + Settings & Utilities @@ -601,7 +587,7 @@ Français - Wilfried الإعدادات - + No systems available. Run Update media database from Settings. لا توجد أنظمة متاحة. شغّل تحديث قاعدة بيانات الوسائط من الإعدادات. @@ -614,12 +600,12 @@ Français - Wilfried - + Loading… جارٍ التحميل… - + No sections @@ -668,227 +654,231 @@ Français - Wilfried Main - + Launch core تشغيل النواة - - + + Change launcher - - + + Update media database تحديث قاعدة بيانات الوسائط - - + + Scrape metadata استخراج البيانات الوصفية - - + + Unhide - - + + Hide - - + + Launch game تشغيل اللعبة - + Remove from favorites إزالة من المفضلة - + Add to favorites أضف إلى المفضلة - + Write to NFC token الكتابة إلى رمز NFC - QR code - رمز QR + رمز QR - - - + + + Default افتراضي - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites المفضلة - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry إعادة المحاولة - + Cancel إلغاء - + Loading systems… جارٍ تحميل الأنظمة… - + Loading games… جارٍ تحميل الألعاب… - + Loading game… - + Loading favorites… جارٍ تحميل المفضلة… - + Loading recently played… جارٍ تحميل آخر ما تم لعبه… - + Loading settings… - + Loading… جارٍ التحميل… @@ -896,182 +886,179 @@ Français - Wilfried MainLayout - + Writing failed فشلت الكتابة - + Put a writable card near the reader ضع بطاقة قابلة للكتابة بالقرب من القارئ - + Zaparoo Frontend - - + + Favorites المفضلة - + Recently Played تم لعبها مؤخرًا - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK حسنًا - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? هل أنت متأكد أنك تريد الخروج؟ - - - - - - - - + + + + + + + + Move تحريك - - + + Select تحديد - - - - - + + + + Close إغلاق - - - - + + + Cancel إلغاء - + Done تم - - - - + + + + Retry إعادة المحاولة - + I understand أفهم - + Adjust - + Save - Start - ابدأ + ابدأ - - - - - + + + + + Open فتح - + Quit إنهاء - - - - + + + + + - - - - - - - - + + + + + + + Back رجوع + - - - + + View @@ -1080,25 +1067,25 @@ Français - Wilfried صفحة - - - - + + + + Options الخيارات - + Change تغيير - + Toggle تبديل - + Scroll تمرير @@ -1111,12 +1098,12 @@ Français - Wilfried جارٍ التحميل… - + %1 entries %1 عناصر - + %1 / %2 @@ -1144,6 +1131,19 @@ Français - Wilfried إلغاء + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1187,66 +1187,66 @@ Français - Wilfried عام - - - - + + + + Language اللغة - - + + Orientation - - + + Browsing layout تخطيط التصفح - - + + Button style نمط الأزرار - - + + Screensaver شاشة التوقف - - + + Library المكتبة - + Discover arcade alternate versions - - + + Preferred artwork - + Update media database تحديث قاعدة بيانات الوسائط - + Scrape metadata استخراج البيانات الوصفية - + Re-scrape existing @@ -1255,475 +1255,472 @@ Français - Wilfried متقدم - + Mouse support دعم الفأرة - + Debug logging تسجيل التصحيح - + Upload log file رفع ملف السجل - + About / License حول / الترخيص - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing جارٍ التحسين + - Paused متوقف مؤقتًا + - In progress قيد التنفيذ - + %1 indexed تمت فهرسة %1 - + %1 scraped تم استخراج %1 - + Cancel إلغاء - + Start ابدأ - + Upload رفع - + Open فتح - Default - افتراضي + افتراضي - + English الإنجليزية - + Italian الإيطالية - + Spanish - + Basque - + German الألمانية - + Greek اليونانية - + Japanese اليابانية - + Korean الكورية - + Dutch الهولندية - + Romanian الرومانية - + Slovak السلوفاكية - + Ukrainian الأوكرانية - + Chinese (Simplified) الصينية المبسطة - + Chinese (Traditional) - + Hebrew العبرية - + Arabic العربية - + Hindi الهندية - + French - - - + + + Auto تلقائي - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view عرض قائمة مفصلة - + Grid view عرض الشبكة - + Full color - + Tinted - + Style B النمط B - + Style C النمط C - + Style D النمط D - + Style A النمط A - + Off إيقاف - + 1 second (testing) ثانية واحدة (اختبار) - + 1 minute دقيقة واحدة - + 2 minutes دقيقتان - + 5 minutes 5 دقائق - + 10 minutes 10 دقائق - + 15 minutes 15 دقيقة - + 30 minutes 30 دقيقة - + %1 seconds %1 ثوانٍ - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - - Resolution - الدقة + الدقة - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings الإعدادات - + No settings available on this platform لا توجد إعدادات متاحة على هذه المنصة @@ -1731,24 +1728,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 أنظمة - - + + %1 / %2 - + No systems in this category لا توجد أنظمة في هذه الفئة - + Loading systems… جارٍ تحميل الأنظمة… @@ -1756,7 +1753,7 @@ Français - Wilfried Tile - + Hidden @@ -1764,9 +1761,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 الصفحة %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_de.ts b/src/ui/translations/frontend_de.ts index 60d2d13d..28f50c23 100644 --- a/src/ui/translations/frontend_de.ts +++ b/src/ui/translations/frontend_de.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Getrennt - + Reconnecting… Verbindung wird wiederhergestellt… - + Connecting… Verbindung wird aufgebaut… - + Core error Core-Fehler - + Paused %1/%2 - + Paused Pausiert - + Opt… - + Optimizing Wird optimiert - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indizierung %1/%2 - + Idx %1/%2 - + Indexing… Indizierung… @@ -324,17 +324,17 @@ Français - Wilfried Scraping pausiert - + Scraping %1/%2 Metadatenabruf %1/%2 - + Scr %1/%2 - + Scraping… Metadaten werden abgerufen… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Noch keine Favoriten - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Favoriten werden geladen… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Ersteinrichtung + Ersteinrichtung - Indexing paused - Indizierung pausiert + Indizierung pausiert - Optimizing database - almost done - Datenbank wird optimiert – fast fertig - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Datenbank wird optimiert – fast fertig - Step %1 of %2 - %3 - Schritt %1 von %2 – %3 + Schritt %1 von %2 – %3 - Step %1 of %2 - Schritt %1 von %2 + Schritt %1 von %2 - Preparing… - Vorbereitung… + Vorbereitung… - Done. %1 files indexed. - Fertig. %1 Dateien indiziert. + Fertig. %1 Dateien indiziert. - Cancel - Abbrechen + Abbrechen - Start scan - Scan starten + Scan starten @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 Dateien - + Loading game… - - + + %1 / %2 - + Loading games… Spiele werden geladen… - + Loading more… Mehr laden… - + No games in this system Keine Spiele in diesem System @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites Favoriten - + Recently Played Zuletzt gespielt - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried Einstellungen - + No systems available. Run Update media database from Settings. Keine Systeme verfügbar. Bitte Mediendatenbank unter Einstellungen aktualisieren. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… Wird geladen… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core Core starten - - + + Change launcher - + Remove from favorites Aus Favoriten entfernen - + Add to favorites Zu Favoriten hinzufügen - + Write to NFC token Auf NFC-Token schreiben - QR code - QR-Code + QR-Code - - + + Launch game Spiel starten - + Go to... - - - + + + View - + Random favorite - + Loading systems… Systeme werden geladen… - + Loading favorites… Favoriten werden geladen… - + Loading games… Spiele werden geladen… - - + + Update media database Mediendatenbank aktualisieren - - + + Scrape metadata Metadaten abrufen - - + + Unhide - - + + Hide - - - + + + Default Standard - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Favoriten - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Wiederholen - + Cancel Abbrechen - + Loading game… - + Loading recently played… Zuletzt gespielte werden geladen… - + Loading settings… - + Loading… Wird geladen… @@ -884,179 +874,176 @@ Français - Wilfried MainLayout - + Writing failed Schreiben fehlgeschlagen - + Put a writable card near the reader Beschreibbare Karte an den Leser halten - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK In Ordnung - + Random game - + No matching games found. - + Are you sure you want to exit? Wirklich beenden? - - + + Select Auswählen - - - - - + + + + Close Schließen - - - - + + + Cancel Abbrechen - + Done Fertig - + I understand Ich verstehe - + Adjust - + Save - Start - Starten + Starten - + Scroll Scrollen + - - - + + View - - - - - - - - + + + + + + + + Move Bewegen - + Zaparoo Frontend - - + + Favorites Favoriten - + Recently Played Zuletzt gespielt - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Öffnen - + Quit Beenden - - - - + + + + + - - - - - - - - + + + + + + + Back Zurück @@ -1065,28 +1052,28 @@ Français - Wilfried Seite - - - - + + + + Options Optionen - - - - + + + + Retry Wiederholen - + Change Ändern - + Toggle Umschalten @@ -1099,12 +1086,12 @@ Français - Wilfried Wird geladen… - + %1 entries %1 Einträge - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried Abbrechen + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1171,26 +1171,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Sprache - - + + Browsing layout Darstellungsmodus - + Mouse support Mausunterstützung - + Update media database Mediendatenbank aktualisieren @@ -1199,47 +1199,47 @@ Français - Wilfried Allgemein - - + + Orientation - - + + Button style Schaltflächenstil - - + + Screensaver Bildschirmschoner - - + + Library Bibliothek - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Metadaten abrufen - + Re-scrape existing @@ -1248,470 +1248,467 @@ Français - Wilfried Erweitert - + Debug logging Debug-Protokollierung - + Upload log file Protokolldatei hochladen - + About / License Über / Lizenz - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Wird optimiert + - Paused Pausiert + - In progress In Bearbeitung - + %1 indexed %1 indiziert - + %1 scraped %1 erfasst - + Cancel Abbrechen - + Start Starten - + Upload Hochladen - + Open Öffnen - + English Englisch - + Italian Italienisch - + Spanish - + Basque - + German Deutsch - + Greek Griechisch - + Japanese Japanisch - + Korean Koreanisch - + Dutch Niederländisch - + Romanian Rumänisch - + Slovak Slowakisch - + Ukrainian Ukrainisch - + Chinese (Simplified) Chinesisch (vereinfacht) - + Chinese (Traditional) - + Hebrew Hebräisch - + Arabic - + Hindi - + French - - - + + + Auto Automatisch - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Detaillierte Listenansicht - + Grid view Rasteransicht - + Full color - + Tinted - + Style B Stil B - + Style C Stil C - + Style D Stil D - + Style A Stil A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Standard + Standard - + Off Aus - + 1 second (testing) 1 Sekunde (Test) - + 1 minute 1 Minute - + 2 minutes 2 Minuten - + 5 minutes 5 Minuten - + 10 minutes 10 Minuten - + 15 minutes 15 Minuten - + 30 minutes 30 Minuten - + %1 seconds %1 Sekunden - - Resolution - Auflösung + Auflösung - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Einstellungen - + No settings available on this platform Keine Einstellungen für diese Plattform verfügbar @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 Systeme - - + + %1 / %2 - + No systems in this category Keine Systeme in dieser Kategorie - + Loading systems… Systeme werden geladen… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Seite %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_el.ts b/src/ui/translations/frontend_el.ts index 6a9177c5..d0587a35 100644 --- a/src/ui/translations/frontend_el.ts +++ b/src/ui/translations/frontend_el.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Αποσυνδεδεμένο - + Reconnecting… Επανασύνδεση… - + Connecting… Σύνδεση… - + Core error Σφάλμα Core - + Paused %1/%2 - + Paused Σε παύση - + Opt… - + Optimizing Βελτιστοποίηση - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Ευρετηρίαση %1/%2 - + Idx %1/%2 - + Indexing… Ευρετηρίαση… @@ -324,17 +324,17 @@ Français - Wilfried Scraping σε παύση - + Scraping %1/%2 Συλλογή %1/%2 - + Scr %1/%2 - + Scraping… Γίνεται συλλογή… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Δεν υπάρχουν αγαπημένα ακόμα - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Φόρτωση αγαπημένων… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Αρχική ρύθμιση + Αρχική ρύθμιση - Indexing paused - Ευρετηρίαση σε παύση + Ευρετηρίαση σε παύση - Optimizing database - almost done - Βελτιστοποίηση βάσης δεδομένων – σχεδόν έτοιμο - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Βελτιστοποίηση βάσης δεδομένων – σχεδόν έτοιμο - Step %1 of %2 - %3 - Βήμα %1 από %2 – %3 + Βήμα %1 από %2 – %3 - Step %1 of %2 - Βήμα %1 από %2 + Βήμα %1 από %2 - Preparing… - Προετοιμασία… + Προετοιμασία… - Done. %1 files indexed. - Ολοκληρώθηκε. %1 αρχεία ευρετηριάστηκαν. + Ολοκληρώθηκε. %1 αρχεία ευρετηριάστηκαν. - Cancel - Ακύρωση + Ακύρωση - Start scan - Έναρξη σάρωσης + Έναρξη σάρωσης @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 αρχεία - + Loading game… - - + + %1 / %2 - + Loading games… Φόρτωση παιχνιδιών… - + Loading more… Φόρτωση περισσότερων… - + No games in this system Δεν υπάρχουν παιχνίδια σε αυτό το σύστημα @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites Αγαπημένα - + Recently Played Πρόσφατα Παιγμένα - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried Ρυθμίσεις - + No systems available. Run Update media database from Settings. Δεν υπάρχουν διαθέσιμα συστήματα. Εκτελέστε Ενημέρωση βάσης δεδομένων από τις Ρυθμίσεις. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… Φόρτωση… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core Εκκίνηση Core - - + + Change launcher - + Remove from favorites Αφαίρεση από αγαπημένα - + Add to favorites Προσθήκη στα αγαπημένα - + Write to NFC token Εγγραφή σε NFC token - QR code - Κωδικός QR + Κωδικός QR - - + + Launch game Εκκίνηση παιχνιδιού - + Go to... - - - + + + View - + Random favorite - + Loading systems… Φόρτωση συστημάτων… - + Loading favorites… Φόρτωση αγαπημένων… - + Loading games… Φόρτωση παιχνιδιών… - - + + Update media database Ενημέρωση βάσης δεδομένων - - + + Scrape metadata Ανάκτηση μεταδεδομένων - - + + Unhide - - + + Hide - - - + + + Default Προεπιλογή - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Αγαπημένα - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Επανάληψη - + Cancel Ακύρωση - + Loading game… - + Loading recently played… Φόρτωση πρόσφατα παιγμένων… - + Loading settings… - + Loading… Φόρτωση… @@ -884,179 +874,176 @@ Français - Wilfried MainLayout - + Writing failed Αποτυχία εγγραφής - + Put a writable card near the reader Τοποθετήστε μια εγγράψιμη κάρτα κοντά στον αναγνώστη - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Εντάξει - + Random game - + No matching games found. - + Are you sure you want to exit? Είστε σίγουροι ότι θέλετε να βγείτε; - - + + Select Επιλογή - - - - - + + + + Close Κλείσιμο - - - - + + + Cancel Ακύρωση - + Done Ολοκληρώθηκε - + I understand Κατανοώ - + Adjust - + Save - Start - Έναρξη + Έναρξη - + Scroll Κύλιση + - - - + + View - - - - - - - - + + + + + + + + Move Μετακίνηση - + Zaparoo Frontend - - + + Favorites Αγαπημένα - + Recently Played Πρόσφατα Παιγμένα - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Άνοιγμα - + Quit Έξοδος - - - - + + + + + - - - - - - - - + + + + + + + Back Πίσω @@ -1065,28 +1052,28 @@ Français - Wilfried Σελίδα - - - - + + + + Options Επιλογές - - - - + + + + Retry Επανάληψη - + Change Αλλαγή - + Toggle Εναλλαγή @@ -1099,12 +1086,12 @@ Français - Wilfried Φόρτωση… - + %1 entries %1 εγγραφές - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried Ακύρωση + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1171,26 +1171,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Γλώσσα - - + + Browsing layout Διάταξη περιήγησης - + Mouse support Υποστήριξη ποντικιού - + Update media database Ενημέρωση βάσης δεδομένων @@ -1199,47 +1199,47 @@ Français - Wilfried Γενικά - - + + Orientation - - + + Button style Στυλ κουμπιών - - + + Screensaver Προφύλαξη οθόνης - - + + Library Βιβλιοθήκη - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Ανάκτηση μεταδεδομένων - + Re-scrape existing @@ -1248,470 +1248,467 @@ Français - Wilfried Για προχωρημένους - + Debug logging Καταγραφή εντοπισμού σφαλμάτων - + Upload log file Αποστολή αρχείου καταγραφής - + About / License Σχετικά / Άδεια - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Βελτιστοποίηση + - Paused Σε παύση + - In progress Σε εξέλιξη - + %1 indexed %1 ευρετηριάστηκαν - + %1 scraped %1 συλλέχθηκαν - + Cancel Ακύρωση - + Start Έναρξη - + Upload Μεταφόρτωση - + Open Άνοιγμα - + English Αγγλικά - + Italian Ιταλικά - + Spanish - + Basque - + German Γερμανικά - + Greek Ελληνικά - + Japanese Ιαπωνικά - + Korean Κορεατικά - + Dutch Ολλανδικά - + Romanian Ρουμανικά - + Slovak Σλοβακικά - + Ukrainian Ουκρανικά - + Chinese (Simplified) Κινεζικά (Απλοποιημένα) - + Chinese (Traditional) - + Hebrew Εβραϊκά - + Arabic - + Hindi - + French - - - + + + Auto Αυτόματο - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Λεπτομερής προβολή λίστας - + Grid view Προβολή πλέγματος - + Full color - + Tinted - + Style B Στυλ B - + Style C Στυλ C - + Style D Στυλ D - + Style A Στυλ A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Προεπιλογή + Προεπιλογή - + Off Απενεργοποιημένο - + 1 second (testing) 1 δευτερόλεπτο (δοκιμή) - + 1 minute 1 λεπτό - + 2 minutes 2 λεπτά - + 5 minutes 5 λεπτά - + 10 minutes 10 λεπτά - + 15 minutes 15 λεπτά - + 30 minutes 30 λεπτά - + %1 seconds %1 δευτερόλεπτα - - Resolution - Ανάλυση + Ανάλυση - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Ρυθμίσεις - + No settings available on this platform Δεν υπάρχουν διαθέσιμες ρυθμίσεις για αυτή την πλατφόρμα @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 συστήματα - - + + %1 / %2 - + No systems in this category Δεν υπάρχουν συστήματα σε αυτή την κατηγορία - + Loading systems… Φόρτωση συστημάτων… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Σελίδα %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_en.ts b/src/ui/translations/frontend_en.ts index 11c36d21..ca911205 100644 --- a/src/ui/translations/frontend_en.ts +++ b/src/ui/translations/frontend_en.ts @@ -238,83 +238,83 @@ Français - Wilfried CoreStatusPill - - + + Disconnected - + Reconnecting… - + Connecting… - + Core error Core error - + Paused %1/%2 - + Paused - + Opt… - + Optimizing - + Idx… - + Scr… - + Indexing %1/%2 - + Idx %1/%2 - + Indexing… - + Scraping %1/%2 - + Scr %1/%2 - + Scraping… @@ -371,12 +371,12 @@ Français - Wilfried - + No favorites yet No favorites yet - + Loading favorite systems… @@ -399,7 +399,7 @@ Français - Wilfried - + %n favorite(s) %n favorite @@ -414,54 +414,8 @@ Français - Wilfried FirstRunIndexModal - - First-time setup - - - - - Indexing paused - - - - - Optimizing database - almost done - - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - - Step %1 of %2 - %3 - - - - - Step %1 of %2 - - - - - Preparing… - - - - - Done. %1 files indexed. - - - - Cancel - Cancel - - - - Start scan - + Cancel @@ -480,34 +434,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 files - + Loading game… - - + + %1 / %2 - + Loading games… - + Loading more… - + No games in this system No games in this system @@ -548,27 +502,27 @@ Français - Wilfried - + Resume - + Favorites Favorites - + Recently Played Recently Played - + Update - + Settings & Utilities @@ -577,7 +531,7 @@ Français - Wilfried Settings - + No systems available. Run Update media database from Settings. @@ -590,12 +544,12 @@ Français - Wilfried - + Loading… Loading… - + No sections @@ -644,227 +598,227 @@ Français - Wilfried Main - + Launch core - - + + Change launcher - + Remove from favorites - + Add to favorites - + Write to NFC token Write to NFC token - - QR code - - - - - + + Launch game - + Go to... - - - + + + View - + Random favorite - + Loading systems… - + Loading favorites… - + Loading games… - - + + Update media database - - + + Scrape metadata - - + + Unhide - - + + Hide - - - + + + Default Default - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Favorites - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Retry - + Cancel Cancel - + Loading game… - + Loading recently played… - + Loading settings… - + Loading… Loading… @@ -872,205 +826,198 @@ Français - Wilfried MainLayout - + Writing failed Writing failed - + Put a writable card near the reader Put a writable card near the reader - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK OK - + Random game - + No matching games found. - + Are you sure you want to exit? - - + + Select Select - - - - - + + + + Close Close - - - - + + + Cancel Cancel - + Done - + I understand - + Adjust - + Save - - Start - - - - + Scroll + - - - + + View - - - - - - - - + + + + + + + + Move Move - + Zaparoo Frontend - - + + Favorites Favorites - + Recently Played Recently Played - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Open - + Quit Quit - - - - + + + + + - - - - - - - - + + + + + + + Back Back - - - - + + + + Options - - - - + + + + Retry Retry - + Change Change - + Toggle Toggle @@ -1083,12 +1030,12 @@ Français - Wilfried Loading… - + %1 entries %1 entries - + %1 / %2 @@ -1116,6 +1063,19 @@ Français - Wilfried Cancel + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1155,539 +1115,532 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Language - - + + Browsing layout Browsing layout - + Mouse support Mouse support - + Update media database - - + + Orientation - - + + Button style - - + + Screensaver - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata - + Re-scrape existing - + Debug logging - + Upload log file - + About / License - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing + - Paused + - In progress - + %1 indexed - + %1 scraped - + Cancel Cancel - + Start - + Upload - + Open Open - + English English - + Italian Italian - + Spanish - + Basque - + German - + Greek - + Japanese - + Korean - + Dutch - + Romanian - + Slovak - + Ukrainian - + Chinese (Simplified) - + Chinese (Traditional) - + Hebrew - + Arabic - + Hindi - + French - - - + + + Auto Auto - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Detailed list view - + Grid view Grid view - + Full color - + Tinted - + Style B - + Style C - + Style D - + Style A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Default + Default - + Off - + 1 second (testing) - + 1 minute - + 2 minutes - + 5 minutes - + 10 minutes - + 15 minutes - + 30 minutes - + %1 seconds - - - Resolution - - - - - + + Display - - + + Controls - - + + Library - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Settings - + No settings available on this platform No settings available on this platform @@ -1695,24 +1648,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 systems - - + + %1 / %2 - + No systems in this category No systems in this category - + Loading systems… @@ -1720,7 +1673,7 @@ Français - Wilfried Tile - + Hidden @@ -1728,9 +1681,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Page %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_es.ts b/src/ui/translations/frontend_es.ts index b53a6289..2ca8e4d9 100644 --- a/src/ui/translations/frontend_es.ts +++ b/src/ui/translations/frontend_es.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Desconectado - + Reconnecting… Reconectando… - + Connecting… Conectando… - + Core error Error del Core - + Paused %1/%2 - + Paused Pausado - + Opt… - + Optimizing Optimizando - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Extrayendo %1/%2 - %3 - + Indexing %1/%2 Indexando %1/%2 - + Idx %1/%2 - + Indexing… Indexando… @@ -324,17 +324,17 @@ Français - Wilfried Scraping en pausa - + Scraping %1/%2 Extrayendo %1/%2 - + Scr %1/%2 - + Scraping… Extrayendo… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Aún no hay favoritos - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Cargando favoritos… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Configuración inicial + Configuración inicial - Indexing paused - Indexación en pausa - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Indexación en pausa - Optimizing database - almost done - Optimizando la base de datos: casi listo + Optimizando la base de datos: casi listo - Step %1 of %2 - %3 - Paso %1 de %2 - %3 + Paso %1 de %2 - %3 - Step %1 of %2 - Paso %1 de %2 + Paso %1 de %2 - Preparing… - Preparando… + Preparando… - Done. %1 files indexed. - Hecho. %1 archivos indexados. + Hecho. %1 archivos indexados. - Cancel - Cancelar + Cancelar - Start scan - Iniciar escaneo + Iniciar escaneo @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 archivos - + Loading game… - - + + %1 / %2 - + Loading games… Cargando juegos… - + Loading more… Cargando más… - + No games in this system No hay juegos en este sistema @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites Favoritos - + Recently Played Recientemente Jugados - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried Ajustes - + No systems available. Run Update media database from Settings. No hay sistemas disponibles. Ejecuta Actualizar la base de datos de medios desde Ajustes. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… Cargando… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core Lanzar core - - + + Change launcher - + Remove from favorites Eliminar de favoritos - + Add to favorites Agregar a favoritos - + Write to NFC token Escribir en token NFC - QR code - Código QR + Código QR - - + + Launch game Iniciar juego - + Go to... - - - + + + View - + Random favorite - + Loading systems… Cargando sistemas… - + Loading favorites… Cargando favoritos… - + Loading games… Cargando juegos… - - + + Update media database Actualizar base de datos de medios - - + + Scrape metadata Extraer metadatos - - + + Unhide - - + + Hide - - - + + + Default Por defecto - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Favoritos - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Reintentar - + Cancel Cancelar - + Loading game… - + Loading recently played… Cargando jugados recientemente… - + Loading settings… - + Loading… Cargando… @@ -884,179 +874,176 @@ Français - Wilfried MainLayout - + Writing failed Error de escritura - + Put a writable card near the reader Pon una tarjeta escribible junto al lector - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Aceptar - + Random game - + No matching games found. - + Are you sure you want to exit? ¿Seguro que quieres salir? - - + + Select Seleccionar - - - - - + + + + Close Cerrar - - - - + + + Cancel Cancelar - + Done Hecho - + I understand Entendido - + Adjust - + Save - Start - Iniciar + Iniciar - + Scroll Desplazar + - - - + + View - - - - - - - - + + + + + + + + Move Mover - + Zaparoo Frontend - - + + Favorites Favoritos - + Recently Played - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Abrir - + Quit Salir - - - - + + + + + - - - - - - - - + + + + + + + Back Atrás @@ -1065,28 +1052,28 @@ Français - Wilfried Página - - - - + + + + Options Opciones - - - - + + + + Retry Reintentar - + Change Cambiar - + Toggle Alternar @@ -1099,12 +1086,12 @@ Français - Wilfried Cargando… - + %1 entries %1 entradas - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried Cancelar + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1171,20 +1171,20 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Idioma - + Mouse support Soporte a ratón - + Update media database Actualizar base de datos de medios @@ -1193,53 +1193,53 @@ Français - Wilfried Ajustes generales - - + + Orientation - - + + Browsing layout Diseño de exploración - - + + Button style Estilo de botón - - + + Screensaver Salvapantallas - - + + Library Biblioteca - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Extraer metadatos - + Re-scrape existing @@ -1248,470 +1248,467 @@ Français - Wilfried Avanzado - + Debug logging Registro de depuración - + Upload log file Subir archivo de registro - + About / License Acerca de / Licencia - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Optimizando + - Paused Pausado + - In progress En progreso - + %1 indexed %1 indexados - + %1 scraped %1 extraídos - + Cancel Cancelar - + Start Iniciar - + Upload Subir - + Open Abrir - + English Inglés - + Italian Italiano - + Spanish - + Basque - + German Alemán - + Greek Griego - + Japanese Japonés - + Korean Coreano - + Dutch Neerlandés - + Romanian Rumano - + Slovak Eslovaco - + Ukrainian Ucraniano - + Chinese (Simplified) Chino (simplificado) - + Chinese (Traditional) - + Hebrew Hebreo - + Arabic - + Hindi - + French - - - + + + Auto Automático - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Vista de lista detallada - + Grid view Vista de cuadrícula - + Full color - + Tinted - + Style B Estilo B - + Style C Estilo C - + Style D Estilo D - + Style A Estilo A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Por defecto + Por defecto - + Off Desactivado - + 1 second (testing) 1 segundo (prueba) - + 1 minute 1 minuto - + 2 minutes 2 minutos - + 5 minutes 5 minutos - + 10 minutes 10 minutos - + 15 minutes 15 minutos - + 30 minutes 30 minutos - + %1 seconds %1 segundos - - Resolution - Resolución + Resolución - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Ajustes - + No settings available on this platform No hay ajustes en esta plataforma @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 sistemas - - + + %1 / %2 - + No systems in this category No hay sistemas en esta categoría - + Loading systems… Cargando sistemas… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Página %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_eu.ts b/src/ui/translations/frontend_eu.ts index ca332348..06b69b8c 100644 --- a/src/ui/translations/frontend_eu.ts +++ b/src/ui/translations/frontend_eu.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Deskonektatuta - + Reconnecting… Berkonektatzen... - + Connecting… Konektatzen... - + Core error Nukleo errorea - + Paused %1/%2 - + Paused Geldituta - + Opt… - + Optimizing Optimizatzen - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scrapeatzen %1/%2 - %3 - + Indexing %1/%2 Indexatzen %1/%2 - + Idx %1/%2 - + Indexing… Indexatzen... @@ -324,17 +324,17 @@ Français - Wilfried Scrapeatzea pausatuta - + Scraping %1/%2 Scrapeatzen %1/%2 - + Scr %1/%2 - + Scraping… Scrapeatzen... @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Ez duzu gogokorik oraindik - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Gogokoak kargatzen... - + %n favorite(s) @@ -434,54 +434,44 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Lehenengo aldiko konfigurazioa + Lehenengo aldiko konfigurazioa - Indexing paused - Indexatzea pausatuta + Indexatzea pausatuta - Optimizing database - almost done - Datu base optimizazioa - ia amaituta + Datu base optimizazioa - ia amaituta - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - Zaparoo-k zure jokuak eskanetu beharra dauka frontend-a erabili aurretik. Honek normaleak minutu gutxi batzuk hartzen ditu. + Zaparoo-k zure jokuak eskanetu beharra dauka frontend-a erabili aurretik. Honek normaleak minutu gutxi batzuk hartzen ditu. - Step %1 of %2 - %3 - %1 pausoa %2tik - %3 + %1 pausoa %2tik - %3 - Step %1 of %2 - %1 pausoa %2tik + %1 pausoa %2tik - Preparing… - Prestatzen + Prestatzen - Done. %1 files indexed. - Eginda. %1 fitxategi indexatuta + Eginda. %1 fitxategi indexatuta - Cancel - Bertan behera utzi + Bertan behera utzi - Start scan - Hasi eskaneoa + Hasi eskaneoa @@ -500,34 +490,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 fitxategi - + Loading game… Jokua kargatzen... - - + + %1 / %2 %1 / %2 - + Loading games… Jokuak kargatzen - + Loading more… Gehiago kargatzen... - + No games in this system Sistema honek ez du jokurik @@ -568,27 +558,27 @@ Français - Wilfried - + Resume - + Favorites Gogokoak - + Recently Played Duela gutxi jolastuta - + Update - + Settings & Utilities @@ -597,7 +587,7 @@ Français - Wilfried Ezarpenak - + No systems available. Run Update media database from Settings. Ez dago sistemarik eskuragarri. Exekutatu Eguneratu baliabide datubasea Aukeretatik @@ -610,12 +600,12 @@ Français - Wilfried - + Loading… Kargatzen… - + No sections @@ -664,227 +654,231 @@ Français - Wilfried Main - + Launch core Exekutatu nukleoa - - + + Change launcher Aldatu abiarazlea - + Remove from favorites Kendu gogokoetatik - + Add to favorites Gehitu gogokoetan - + Write to NFC token Idatzi NFC token-a - QR code - QR kodea + QR kodea - - + + Launch game Abiarazi jokua - + Go to... - - - + + + View - + Random favorite - + Loading systems… Sistemak kargatzen - + Loading favorites… Gogokoak kargatzen - + Loading games… Jokoak kargatzen - - + + Update media database Eguneratu multimedia datu-basea - - + + Scrape metadata Metadatuak scrapeatu - - + + Unhide - - + + Hide - - - + + + Default Lehenetsia - + Current: %1 Unekoa: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Gogokoak - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher Abiarazlea gordetzen - + Saving… Gordetzen... - + Launcher update failed Abiarazle egukeraketak huts egin du - + Error: %1 Errorea: %1 - + Retry Berriro saiatu - + Cancel Utzi - + Loading game… Jokua kargatzen... - + Loading recently played… Duela gutxi jokatutakoak kargatzen... - + Loading settings… - + Loading… Kargatzen… @@ -892,179 +886,176 @@ Français - Wilfried MainLayout - + Writing failed Idazketak huts egin du - + Put a writable card near the reader Jarri idatzi daitekeen txartel bat irakurlearen ondoan - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Ados - + Random game - + No matching games found. - + Are you sure you want to exit? Ziur zaude irten nahi duzula? - - + + Select Aukeratu - - - - - + + + + Close Itxi - - - - + + + Cancel Utzi - + Done Eginda - + I understand Ulertzen dut - + Adjust - + Save - Start - Hasi + Hasi - + Scroll Scroll-a egin + - - - + + View - - - - - - - - + + + + + + + + Move Mugitu - + Zaparoo Frontend Zaparoo Frontend - - + + Favorites Gogokoak - + Recently Played Duela gutxi jokatuak - + Quit and restart Zaparoo Frontend? Itxi eta Zaparoo Frontend berabiarazi - + In order to apply this setting we need to restart the frontend. Ezarpenak indarrean jartzeko frontend-a berrabiarazi behar dugu - + Quit Zaparoo Frontend? Itxi Zaparoo Frontend? - - - - - + + + + + Open Ireki - + Quit Irten - - - - + + + + + - - - - - - - - + + + + + + + Back Atzera @@ -1073,28 +1064,28 @@ Français - Wilfried Orria - - - - + + + + Options Aukerak - - - - + + + + Retry Berriro saiatu - + Change Aldatu - + Toggle Txandakatu @@ -1107,12 +1098,12 @@ Français - Wilfried Kargatzen… - + %1 entries %1 sarrera - + %1 / %2 %1 / %2 @@ -1140,6 +1131,19 @@ Français - Wilfried Utzi + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1179,26 +1183,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Hizkuntza - - + + Browsing layout Nabigazio diseinua - + Mouse support Saguaren euskarria - + Update media database Eguneratu multimedia datu-basea @@ -1207,47 +1211,47 @@ Français - Wilfried Orokorra - - + + Orientation Orientazioa - - + + Button style Botoi estiloa - - + + Screensaver Pantaila babeslea - - + + Library Liburutegia - + Discover arcade alternate versions Aurkitu arcade bertsio alternatiboak - - + + Preferred artwork Hobetsitako artelana - + Scrape metadata Metadatuak scrapeatu - + Re-scrape existing Berriro scrapeatzea existitzen da @@ -1256,470 +1260,467 @@ Français - Wilfried Aurreratua - + Debug logging Arazte erregistroa - + Upload log file Igo erregistro fitxategia - + About / License Buruz / Lizentzia - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Optimizatzen + - Paused Geldituta + - In progress Abian - + %1 indexed %1 indexatuta - + %1 scraped %1 scrapetatuta - + Cancel Utzi - + Start Hasi - + Upload Igo - + Open Ireki - + English Ingelera - + Italian Italiera - + Spanish - + Basque - + German Alemaniera - + Greek Grekoa - + Japanese Japoniera - + Korean Koreera - + Dutch Holandera - + Romanian Errumaniera - + Slovak eslovakiera - + Ukrainian Ukraniera - + Chinese (Simplified) Txinera (sinplifikatua) - + Chinese (Traditional) - + Hebrew - + Arabic Arabiera - + Hindi HIndia - + French - - - + + + Auto Auto - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW CW biratua - + Rotated CCW CCW biratua - + Horizontal Horizontala - + Detailed list view Xehetasun listaren ikuspegia - + Grid view Sarearen ikuspegia - + Full color - + Tinted - + Style B B estiloa - + Style C C estiloa - + Style D D estiloa - + Style A A estiloa - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Lehenetsia + Lehenetsia - + Off Desaktibatuta - + 1 second (testing) segundu 1 (frogatzen) - + 1 minute minutu 1 - + 2 minutes 2 minutu - + 5 minutes 5 minutu - + 10 minutes 10 minutu - + 15 minutes 15 minutu - + 30 minutes 30 minutu - + %1 seconds %1 segundu - - Resolution - Bereizmena + Bereizmena - - + + Display - - + + Controls - - + + Support - + Image Irudia - + Thumbnail Miniatura - + Box art Kaxaren artelana - + 3D box art Kaxaren 3D artelana - + Screenshot Pantaila argazkoa - + Wheel Gurpila - + Title screen Izenburu pantaila - + Map Mapa - + Marquee Karpa - + Fan art Fan artelana - + Box side Kaxa alboa - + Box back Kaxa atzekaldea - + Settings Ezarpenak - + No settings available on this platform Ez dago ezarpenik plataforma honetan @@ -1727,24 +1728,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 sistema - - + + %1 / %2 %1 / %2 - + No systems in this category Ez dago sistemarik kategoria honetan - + Loading systems… Sistemak kargatzen @@ -1752,7 +1753,7 @@ Français - Wilfried Tile - + Hidden @@ -1760,9 +1761,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 %1 orria / %2tik + + + Page %1 + + diff --git a/src/ui/translations/frontend_fr.ts b/src/ui/translations/frontend_fr.ts index 9388a948..c95131b5 100644 --- a/src/ui/translations/frontend_fr.ts +++ b/src/ui/translations/frontend_fr.ts @@ -241,83 +241,83 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Déconnecté - + Reconnecting… Reconnexion… - + Connecting… Connexion… - + Core error Erreur Core - + Paused %1/%2 En pause %1/%2 - + Paused En pause - + Opt… Opt… - + Optimizing Optimisation - + Idx… Idx… - + Scr… Scr… - + Indexing %1/%2 Indexation %1/%2 - + Idx %1/%2 Idx %1/%2 - + Indexing… Indexation… - + Scraping %1/%2 Scraping %1/%2 - + Scr %1/%2 Scr %1/%2 - + Scraping… Scraping… @@ -374,12 +374,12 @@ Français - Wilfried - + No favorites yet Aucun favori pour l'instant - + Loading favorite systems… @@ -402,7 +402,7 @@ Français - Wilfried Chargement des favoris… - + %n favorite(s) @@ -413,54 +413,44 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Configuration initiale + Configuration initiale - Indexing paused - Indexation en pause + Indexation en pause - Optimizing database - almost done - Optimisation de la base de données - presque terminé + Optimisation de la base de données - presque terminé - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - Zaparoo doit analyser vos jeux avant de pouvoir utiliser le frontend. Cela prend généralement quelques minutes. + Zaparoo doit analyser vos jeux avant de pouvoir utiliser le frontend. Cela prend généralement quelques minutes. - Step %1 of %2 - %3 - Étape %1 sur %2 - %3 + Étape %1 sur %2 - %3 - Step %1 of %2 - Étape %1 sur %2 + Étape %1 sur %2 - Preparing… - Préparation… + Préparation… - Done. %1 files indexed. - Terminé. %1 fichiers indexés. + Terminé. %1 fichiers indexés. - Cancel - Annuler + Annuler - Start scan - Lancer l'analyse + Lancer l'analyse @@ -479,34 +469,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 fichiers - + Loading game… Chargement du jeu… - - + + %1 / %2 %1 / %2 - + Loading games… Chargement des jeux… - + Loading more… Chargement… - + No games in this system Aucun jeu dans ce système @@ -547,27 +537,27 @@ Français - Wilfried Autres - + Resume Reprendre - + Favorites Favoris - + Recently Played Joués récemment - + Update Mise à jour - + Settings & Utilities Réglages et utilitaires @@ -576,7 +566,7 @@ Français - Wilfried Réglages - + No systems available. Run Update media database from Settings. Aucun système disponible. Lancez Mettre à jour la base de données média depuis les Réglages. @@ -589,12 +579,12 @@ Français - Wilfried Aller à... - + Loading… Chargement… - + No sections Aucune section @@ -643,227 +633,231 @@ Français - Wilfried Main - + Launch core Lancer le core - - + + Change launcher Modifier le lanceur - + Remove from favorites Retirer des favoris - + Add to favorites Ajouter aux favoris - + Write to NFC token Écrire sur un badge NFC - QR code - QR code + QR code - - + + Launch game Lancer le jeu - + Go to... Aller à... - - - + + + View Afficher - + Loading systems… Chargement des systèmes… - + Loading favorites… Chargement des favoris… - + Loading games… Chargement des jeux… - - + + Update media database Mettre à jour la base de données média - - + + Scrape metadata Scraper les métadonnées - - + + Unhide Afficher - - + + Hide Masquer - - - + + + Default Par défaut - + Current: %1 Actuel : %1 - - - - - + + + + + Random game - + + Write with QR code + + + + Show: %1 - - + + Favorites Favoris - - + + All - + Show - + Sort: %1 - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - - + + A-Z - + Sort - + Saving launcher Enregistrement du lanceur - + Saving… Enregistrement… - + Launcher update failed Échec de la mise à jour du lanceur - + Error: %1 Erreur : %1 - + Retry Réessayer - + Cancel Annuler - + Loading game… Chargement du jeu… - + Loading recently played… Chargement des jeux récents… - + Loading settings… Chargement des réglages… - + Loading… Chargement… @@ -871,205 +865,202 @@ Français - Wilfried MainLayout - + Writing failed Échec de l'écriture - + Put a writable card near the reader Placez une carte inscriptible près du lecteur - + Update Zaparoo Core Mettre à jour Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. Ce frontend nécessite Zaparoo Core %1 ou plus récent. Vous utilisez la version %2. Certaines fonctionnalités peuvent ne pas fonctionner tant que la mise à jour n'est pas effectuée. - - - + + + OK OK - + Random game - + No matching games found. - + Are you sure you want to exit? Êtes-vous sûr de vouloir quitter ? - - + + Select Sélectionner - - - - - + + + + Close Fermer - - - - + + + Cancel Annuler - + Done Terminé - + I understand J'ai compris - + Adjust Ajuster - + Save Enregistrer - Start - Démarrer + Démarrer - + Scroll Défiler + - - - + + View Afficher - - - - - - - - + + + + + + + + Move Déplacer - + Zaparoo Frontend Zaparoo Frontend - - + + Favorites Favoris - + Recently Played Joués récemment - + Quit and restart Zaparoo Frontend? Quitter et redémarrer Zaparoo Frontend ? - + In order to apply this setting we need to restart the frontend. Pour appliquer ce réglage, le frontend doit être redémarré. - + Quit Zaparoo Frontend? Quitter Zaparoo Frontend ? - - - - - + + + + + Open Ouvrir - + Quit Quitter - - - - + + + + + - - - - - - - - + + + + + + + Back Retour - - - - + + + + Options Options - - - - + + + + Retry Réessayer - + Change Modifier - + Toggle Basculer @@ -1082,12 +1073,12 @@ Français - Wilfried Chargement… - + %1 entries %1 entrées - + %1 / %2 %1 / %2 @@ -1115,6 +1106,19 @@ Français - Wilfried Annuler + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1154,539 +1158,536 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Langue - - + + Browsing layout Disposition de navigation - + Mouse support Prise en charge de la souris - + Update media database Mettre à jour la base de données média - - + + Orientation Orientation - - + + Button style Style des boutons - - + + Screensaver Économiseur d'écran - + Discover arcade alternate versions Découvrir les versions arcade alternatives - - + + Preferred artwork Illustration préférée - + Scrape metadata Scraper les métadonnées - + Re-scrape existing Re-scraper l'existant - + Debug logging Journalisation de débogage - + Upload log file Envoyer le fichier journal - + About / License À propos / Licence - - + + Clock format Format de l'heure - - + + System names Noms des systèmes - - + + Browsing Navigation - + Analog video Vidéo analogique - + CRT mode Mode CRT - - + + Video standard Standard vidéo - + Screen position Position de l'écran - - + + System logos Logos des systèmes - + Show hidden items Afficher les éléments masqués - + Show original filenames Afficher les noms de fichiers d'origine - + Reduce motion Réduire les animations - + Optimizing Optimisation + - Paused En pause + - In progress En cours - + %1 indexed %1 indexés - + %1 scraped %1 scrapés - + Cancel Annuler - + Start Démarrer - + Upload Envoyer - + Open Ouvrir - + English Anglais - + Italian Italien - + Spanish Espagnol - + Basque Basque - + German Allemand - + Greek Grec - + Japanese Japonais - + Korean Coréen - + Dutch Néerlandais - + Romanian Roumain - + Slovak Slovaque - + Ukrainian Ukrainien - + Chinese (Simplified) Chinois (simplifié) - + Chinese (Traditional) Chinois (traditionnel) - + Hebrew Hébreu - + Arabic Arabe - + Hindi Hindi - + French Français - - - + + + Auto Auto - + 12-hour 12 heures - + 24-hour 24 heures - + Americas Amériques - + Europe Europe - + Japan Japon - + Automatic Automatique - + Rotated CW Rotation horaire - + Rotated CCW Rotation antihoraire - + Horizontal Horizontal - + Detailed list view Vue liste détaillée - + Grid view Vue en grille - + Full color Couleur intégrale - + Tinted Teinté - + Style B Style B - + Style C Style C - + Style D Style D - + Style A Style A - + PAL (50 Hz) PAL (50 Hz) - + 480i (60 Hz) 480i (60 Hz) - + NTSC (60 Hz) NTSC (60 Hz) - + Loading settings… Chargement des réglages… - Default - Par défaut + Par défaut - + Off Désactivé - + 1 second (testing) 1 seconde (test) - + 1 minute 1 minute - + 2 minutes 2 minutes - + 5 minutes 5 minutes - + 10 minutes 10 minutes - + 15 minutes 15 minutes - + 30 minutes 30 minutes - + %1 seconds %1 secondes - - Resolution - Résolution + Résolution - - + + Display Affichage - - + + Controls Commandes - - + + Library Bibliothèque - - + + Support Assistance - + Image Image - + Thumbnail Vignette - + Box art Box art - + 3D box art 3D box art - + Screenshot Capture d'écran - + Wheel Wheel - + Title screen Écran-titre - + Map Carte - + Marquee Marquee - + Fan art Fan art - + Box side Tranche de boîtier - + Box back Dos de la jaquette - + Settings Réglages - + No settings available on this platform Aucun réglage disponible sur cette plateforme @@ -1694,24 +1695,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 systèmes - - + + %1 / %2 %1 / %2 - + No systems in this category Aucun système dans cette catégorie - + Loading systems… Chargement des systèmes… @@ -1719,7 +1720,7 @@ Français - Wilfried Tile - + Hidden Masqué @@ -1727,9 +1728,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Page %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_he.ts b/src/ui/translations/frontend_he.ts index 7b445640..402dcc71 100644 --- a/src/ui/translations/frontend_he.ts +++ b/src/ui/translations/frontend_he.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected מנותק - + Reconnecting… מתחבר מחדש… - + Connecting… מתחבר… - + Core error שגיאת Core - + Paused %1/%2 - + Paused מושהה - + Opt… - + Optimizing מבצע אופטימיזציה - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried מאנדקס %1/%2 - %3 - + Indexing %1/%2 מאנדקס %1/%2 - + Idx %1/%2 - + Indexing… מאנדקס… @@ -324,17 +324,17 @@ Français - Wilfried אוסף נתונים %1/%2 - %3 - + Scraping %1/%2 אוסף נתונים %1/%2 - + Scr %1/%2 - + Scraping… אוסף נתונים… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet עדיין אין מועדפים - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried טוען מועדפים… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - הגדרה ראשונית + הגדרה ראשונית - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - Optimizing database - almost done - מבצע אופטימיזציה למסד הנתונים - כמעט סיים + מבצע אופטימיזציה למסד הנתונים - כמעט סיים - Indexing paused - יצירת האינדקס הושהתה + יצירת האינדקס הושהתה - Step %1 of %2 - %3 - שלב %1 מתוך %2 - %3 + שלב %1 מתוך %2 - %3 - Step %1 of %2 - שלב %1 מתוך %2 + שלב %1 מתוך %2 - Preparing… - מכין… + מכין… - Done. %1 files indexed. - הושלם. %1 קבצים אונדקסו. + הושלם. %1 קבצים אונדקסו. - Cancel - ביטול + ביטול - Start scan - התחל סריקה + התחל סריקה @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 קבצים - + Loading game… - - + + %1 / %2 - + No games in this system אין משחקים במערכת זו - + Loading games… טוען משחקים… - + Loading more… טוען עוד… @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites מועדפים - + Recently Played שוחקו לאחרונה - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried הגדרות - + No systems available. Run Update media database from Settings. אין מערכות זמינות. הפעילו "עדכון מסד נתוני מדיה" מההגדרות. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… טוען… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core הפעל את הליבה - - + + Change launcher - - + + Update media database עדכון מסד נתוני המדיה - - + + Scrape metadata איסוף מטא-נתונים - - + + Unhide - - + + Hide - - + + Launch game הפעל משחק - + Remove from favorites הסר מהמועדפים - + Add to favorites הוסף למועדפים - + Write to NFC token כתוב לטוקן NFC - QR code - קוד QR + קוד QR - - - + + + Default ברירת מחדל - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites מועדפים - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry נסה שוב - + Cancel ביטול - + Loading systems… טוען מערכות… - + Loading games… טוען משחקים… - + Loading game… - + Loading favorites… טוען מועדפים… - + Loading recently played… טוען את הפריטים ששוחקו לאחרונה… - + Loading settings… - + Loading… טוען… @@ -884,182 +874,179 @@ Français - Wilfried MainLayout - + Writing failed הכתיבה נכשלה - + Put a writable card near the reader הניחו כרטיס הניתן לכתיבה ליד הקורא - + Zaparoo Frontend - - + + Favorites מועדפים - + Recently Played שוחקו לאחרונה - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK אישור - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? האם אתה בטוח שברצונך לצאת? - - - - - - - - + + + + + + + + Move הזזה - - + + Select בחירה - - - - - + + + + Close סגור - - - - + + + Cancel ביטול - + Done הושלם - - - - + + + + Retry נסה שוב - + I understand הבנתי - + Adjust - + Save - Start - התחל + התחל - - - - - + + + + + Open פתח - + Quit יציאה - - - - + + + + + - - - - - - - - + + + + + + + Back חזרה + - - - + + View @@ -1068,25 +1055,25 @@ Français - Wilfried עמוד - - - - + + + + Options אפשרויות - + Change שינוי - + Toggle החלף - + Scroll גלילה @@ -1099,12 +1086,12 @@ Français - Wilfried טוען… - + %1 entries %1 פריטים - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried ביטול + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1175,66 +1175,66 @@ Français - Wilfried כללי - - - - + + + + Language שפה - - + + Orientation - - + + Browsing layout פריסת עיון - - + + Button style סגנון כפתורים - - + + Screensaver שומר מסך - - + + Library ספרייה - + Discover arcade alternate versions - - + + Preferred artwork - + Update media database עדכון מסד נתוני המדיה - + Scrape metadata איסוף מטא-נתונים - + Re-scrape existing @@ -1243,475 +1243,472 @@ Français - Wilfried מתקדם - + Mouse support תמיכת עכבר - + Debug logging רישום ניפוי שגיאות - + Upload log file העלאת קובץ יומן - + About / License אודות / רישיון - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing מבצע אופטימיזציה + - Paused מושהה + - In progress בתהליך - + %1 indexed %1 אונדקסו - + %1 scraped %1 נאספו - + Cancel ביטול - + Start התחל - + Upload העלאה - + Open פתח - Default - ברירת מחדל + ברירת מחדל - + English אנגלית - + Italian איטלקית - + Spanish - + Basque - + German גרמנית - + Greek יוונית - + Japanese יפנית - + Korean קוריאנית - + Dutch הולנדית - + Romanian רומנית - + Slovak סלובקית - + Ukrainian אוקראינית - + Chinese (Simplified) סינית (מפושטת) - + Chinese (Traditional) - + Hebrew עברית - + Arabic - + Hindi - + French - - - + + + Auto אוטומטי - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view תצוגת רשימה מפורטת - + Grid view תצוגת רשת - + Full color - + Tinted - + Style B סגנון B - + Style C סגנון C - + Style D סגנון D - + Style A סגנון A - + Off כבוי - + 1 second (testing) שנייה אחת (לבדיקה) - + 1 minute דקה אחת - + 2 minutes 2 דקות - + 5 minutes 5 דקות - + 10 minutes 10 דקות - + 15 minutes 15 דקות - + 30 minutes 30 דקות - + %1 seconds %1 שניות - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - - Resolution - רזולוציה + רזולוציה - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings הגדרות - + No settings available on this platform אין הגדרות זמינות בפלטפורמה זו @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 מערכות - - + + %1 / %2 - + No systems in this category אין מערכות בקטגוריה זו - + Loading systems… טוען מערכות… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 עמוד %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_hi.ts b/src/ui/translations/frontend_hi.ts index 3d3bca9c..e9abb66c 100644 --- a/src/ui/translations/frontend_hi.ts +++ b/src/ui/translations/frontend_hi.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected डिस्कनेक्टेड - + Reconnecting… फिर से कनेक्ट किया जा रहा है… - + Connecting… कनेक्ट किया जा रहा है… - + Core error कोर त्रुटि - + Paused %1/%2 - + Paused रोक दिया गया - + Opt… - + Optimizing अनुकूलित किया जा रहा है - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried इंडेक्सिंग %1/%2 - %3 - + Indexing %1/%2 इंडेक्सिंग %1/%2 - + Idx %1/%2 - + Indexing… इंडेक्सिंग… @@ -324,17 +324,17 @@ Français - Wilfried स्क्रैपिंग %1/%2 - %3 - + Scraping %1/%2 स्क्रैपिंग %1/%2 - + Scr %1/%2 - + Scraping… स्क्रैपिंग… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet अभी तक कोई पसंदीदा नहीं - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried पसंदीदा लोड हो रहे हैं… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - पहली बार सेटअप + पहली बार सेटअप - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - Optimizing database - almost done - डेटाबेस अनुकूलित किया जा रहा है - लगभग पूरा + डेटाबेस अनुकूलित किया जा रहा है - लगभग पूरा - Indexing paused - इंडेक्सिंग रोकी गई + इंडेक्सिंग रोकी गई - Step %1 of %2 - %3 - %2 में से चरण %1 - %3 + %2 में से चरण %1 - %3 - Step %1 of %2 - %2 में से चरण %1 + %2 में से चरण %1 - Preparing… - तैयार किया जा रहा है… + तैयार किया जा रहा है… - Done. %1 files indexed. - पूरा हुआ। %1 फ़ाइलें इंडेक्स की गईं। + पूरा हुआ। %1 फ़ाइलें इंडेक्स की गईं। - Cancel - रद्द करें + रद्द करें - Start scan - स्कैन शुरू करें + स्कैन शुरू करें @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 फ़ाइलें - + Loading game… - - + + %1 / %2 - + No games in this system इस सिस्टम में कोई गेम नहीं है - + Loading games… गेम लोड हो रहे हैं… - + Loading more… और लोड हो रहा है… @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites पसंदीदा - + Recently Played हाल ही में खेले गए - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried सेटिंग्स - + No systems available. Run Update media database from Settings. कोई सिस्टम उपलब्ध नहीं है। सेटिंग्स से मीडिया डेटाबेस अपडेट चलाएँ। @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… लोड हो रहा है… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core कोर चलाएँ - - + + Change launcher - - + + Update media database मीडिया डेटाबेस अपडेट करें - - + + Scrape metadata मेटाडेटा स्क्रैप करें - - + + Unhide - - + + Hide - - + + Launch game गेम चलाएँ - + Remove from favorites पसंदीदा से हटाएँ - + Add to favorites पसंदीदा में जोड़ें - + Write to NFC token NFC टोकन पर लिखें - QR code - QR कोड + QR कोड - - - + + + Default डिफ़ॉल्ट - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites पसंदीदा - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry फिर से प्रयास करें - + Cancel रद्द करें - + Loading systems… सिस्टम लोड हो रहे हैं… - + Loading games… गेम लोड हो रहे हैं… - + Loading game… - + Loading favorites… पसंदीदा लोड हो रहे हैं… - + Loading recently played… हाल ही में खेले गए लोड हो रहे हैं… - + Loading settings… - + Loading… लोड हो रहा है… @@ -884,182 +874,179 @@ Français - Wilfried MainLayout - + Writing failed लिखना विफल हुआ - + Put a writable card near the reader रीडर के पास लिखने योग्य कार्ड रखें - + Zaparoo Frontend - - + + Favorites पसंदीदा - + Recently Played हाल ही में खेले गए - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK ठीक है - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? क्या आप वाकई बाहर निकलना चाहते हैं? - - - - - - - - + + + + + + + + Move स्थानांतरित करें - - + + Select चुनें - - - - - + + + + Close बंद करें - - - - + + + Cancel रद्द करें - + Done पूरा - - - - + + + + Retry फिर से प्रयास करें - + I understand मैं समझ गया - + Adjust - + Save - Start - शुरू करें + शुरू करें - - - - - + + + + + Open खोलें - + Quit बंद करें - - - - + + + + + - - - - - - - - + + + + + + + Back वापस + - - - + + View @@ -1068,25 +1055,25 @@ Français - Wilfried पृष्ठ - - - - + + + + Options विकल्प - + Change बदलें - + Toggle टॉगल - + Scroll स्क्रॉल @@ -1099,12 +1086,12 @@ Français - Wilfried लोड हो रहा है… - + %1 entries %1 प्रविष्टियाँ - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried रद्द करें + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1175,66 +1175,66 @@ Français - Wilfried सामान्य - - - - + + + + Language भाषा - - + + Orientation - - + + Browsing layout ब्राउज़िंग लेआउट - - + + Button style बटन शैली - - + + Screensaver स्क्रीनसेवर - - + + Library लाइब्रेरी - + Discover arcade alternate versions - - + + Preferred artwork - + Update media database मीडिया डेटाबेस अपडेट करें - + Scrape metadata मेटाडेटा स्क्रैप करें - + Re-scrape existing @@ -1243,475 +1243,472 @@ Français - Wilfried उन्नत - + Mouse support माउस समर्थन - + Debug logging डीबग लॉगिंग - + Upload log file लॉग फ़ाइल अपलोड करें - + About / License परिचय / लाइसेंस - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing अनुकूलित किया जा रहा है + - Paused रोक दिया गया + - In progress प्रगति पर - + %1 indexed %1 इंडेक्स किए गए - + %1 scraped %1 स्क्रैप किए गए - + Cancel रद्द करें - + Start शुरू करें - + Upload अपलोड - + Open खोलें - Default - डिफ़ॉल्ट + डिफ़ॉल्ट - + English अंग्रेज़ी - + Italian इतालवी - + Spanish - + Basque - + German जर्मन - + Greek ग्रीक - + Japanese जापानी - + Korean कोरियाई - + Dutch डच - + Romanian रोमानियाई - + Slovak स्लोवाक - + Ukrainian यूक्रेनी - + Chinese (Simplified) चीनी (सरलीकृत) - + Chinese (Traditional) - + Hebrew हिब्रू - + Arabic अरबी - + Hindi हिंदी - + French - - - + + + Auto स्वचालित - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view विस्तृत सूची दृश्य - + Grid view ग्रिड दृश्य - + Full color - + Tinted - + Style B शैली B - + Style C शैली C - + Style D शैली D - + Style A शैली A - + Off बंद - + 1 second (testing) 1 सेकंड (परीक्षण) - + 1 minute 1 मिनट - + 2 minutes 2 मिनट - + 5 minutes 5 मिनट - + 10 minutes 10 मिनट - + 15 minutes 15 मिनट - + 30 minutes 30 मिनट - + %1 seconds %1 सेकंड - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - - Resolution - रिज़ॉल्यूशन + रिज़ॉल्यूशन - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings सेटिंग्स - + No settings available on this platform इस प्लेटफ़ॉर्म पर कोई सेटिंग उपलब्ध नहीं है @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 सिस्टम - - + + %1 / %2 - + No systems in this category इस श्रेणी में कोई सिस्टम नहीं है - + Loading systems… सिस्टम लोड हो रहे हैं… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 पृष्ठ %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_it.ts b/src/ui/translations/frontend_it.ts index 0ab59948..fa7ff763 100644 --- a/src/ui/translations/frontend_it.ts +++ b/src/ui/translations/frontend_it.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Disconnesso - + Reconnecting… Riconnessione… - + Connecting… Connessione… - + Core error Errore del Core - + Paused %1/%2 - + Paused In pausa - + Opt… - + Optimizing Ottimizzazione - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Recupero %1/%2 - %3 - + Indexing %1/%2 Indicizzazione %1/%2 - + Idx %1/%2 - + Indexing… Indicizzazione… @@ -324,17 +324,17 @@ Français - Wilfried Recupero in pausa - + Scraping %1/%2 Recupero %1/%2 - + Scr %1/%2 - + Scraping… Recupero metadati… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Nessun preferito ancora - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Caricamento preferiti… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Configurazione iniziale + Configurazione iniziale - Indexing paused - Indicizzazione in pausa + Indicizzazione in pausa - Optimizing database - almost done - Ottimizzazione database - quasi finito - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Ottimizzazione database - quasi finito - Step %1 of %2 - %3 - Passo %1 di %2 - %3 + Passo %1 di %2 - %3 - Step %1 of %2 - Passo %1 di %2 + Passo %1 di %2 - Preparing… - Preparazione… + Preparazione… - Done. %1 files indexed. - Fatto. %1 file indicizzati. + Fatto. %1 file indicizzati. - Cancel - Annulla + Annulla - Start scan - Avvia scansione + Avvia scansione @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 file - + Loading game… - - + + %1 / %2 - + Loading games… Caricamento giochi… - + Loading more… Caricamento altro… - + No games in this system Nessun gioco in questo sistema @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites Preferiti - + Recently Played Giocati di recente - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried Impostazioni - + No systems available. Run Update media database from Settings. Nessun sistema disponibile. Esegui Aggiorna database multimediale dalle Impostazioni. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… Caricamento… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core Avvia core - - + + Change launcher - + Remove from favorites Rimuovi dai preferiti - + Add to favorites Aggiungi ai preferiti - + Write to NFC token Scrivi sul token NFC - QR code - Codice QR + Codice QR - - + + Launch game Avvia gioco - + Go to... - - - + + + View - + Random favorite - + Loading systems… Caricamento sistemi… - + Loading favorites… Caricamento preferiti… - + Loading games… Caricamento giochi… - - + + Update media database Aggiorna database multimediale - - + + Scrape metadata Recupera metadati - - + + Unhide - - + + Hide - - - + + + Default Predefinito - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Preferiti - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Riprova - + Cancel Annulla - + Loading game… - + Loading recently played… Caricamento recenti… - + Loading settings… - + Loading… Caricamento… @@ -884,179 +874,176 @@ Français - Wilfried MainLayout - + Writing failed Scrittura non riuscita - + Put a writable card near the reader Avvicina una scheda scrivibile al lettore - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Va bene - + Random game - + No matching games found. - + Are you sure you want to exit? Sei sicuro di voler uscire? - - + + Select Seleziona - - - - - + + + + Close Chiudi - - - - + + + Cancel Annulla - + Done Fatto - + I understand Ho capito - + Adjust - + Save - Start - Avvia + Avvia - + Scroll Scorri + - - - + + View - - - - - - - - + + + + + + + + Move Muovi - + Zaparoo Frontend - - + + Favorites Preferiti - + Recently Played Giocati di recente - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Apri - + Quit Esci - - - - + + + + + - - - - - - - - + + + + + + + Back Indietro @@ -1065,28 +1052,28 @@ Français - Wilfried Pagina - - - - + + + + Options Opzioni - - - - + + + + Retry Riprova - + Change Cambia - + Toggle Alterna @@ -1099,12 +1086,12 @@ Français - Wilfried Caricamento… - + %1 entries %1 elementi - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried Annulla + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1171,26 +1171,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Lingua - - + + Browsing layout Layout navigazione - + Mouse support Supporto mouse - + Update media database Aggiorna database multimediale @@ -1199,47 +1199,47 @@ Français - Wilfried Generale - - + + Orientation - - + + Button style Stile pulsanti - - + + Screensaver Salvaschermo - - + + Library Libreria - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Recupera metadati - + Re-scrape existing @@ -1248,470 +1248,467 @@ Français - Wilfried Avanzate - + Debug logging Log di debug - + Upload log file Carica file di log - + About / License Informazioni / Licenza - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Ottimizzazione + - Paused In pausa + - In progress In corso - + %1 indexed %1 indicizzati - + %1 scraped %1 recuperati - + Cancel Annulla - + Start Avvia - + Upload Carica - + Open Apri - + English Inglese - + Italian Italiano - + Spanish - + Basque - + German Tedesco - + Greek Greco - + Japanese Giapponese - + Korean Coreano - + Dutch Olandese - + Romanian Rumeno - + Slovak Slovacco - + Ukrainian Ucraino - + Chinese (Simplified) Cinese (semplificato) - + Chinese (Traditional) - + Hebrew Ebraico - + Arabic - + Hindi - + French - - - + + + Auto Automatico - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Vista elenco dettagliata - + Grid view Vista griglia - + Full color - + Tinted - + Style B Stile B - + Style C Stile C - + Style D Stile D - + Style A Stile A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Predefinito + Predefinito - + Off Disattivato - + 1 second (testing) 1 secondo (test) - + 1 minute 1 minuto - + 2 minutes 2 minuti - + 5 minutes 5 minuti - + 10 minutes 10 minuti - + 15 minutes 15 minuti - + 30 minutes 30 minuti - + %1 seconds %1 secondi - - Resolution - Risoluzione + Risoluzione - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Impostazioni - + No settings available on this platform Nessuna impostazione disponibile su questa piattaforma @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 sistemi - - + + %1 / %2 - + No systems in this category Nessun sistema in questa categoria - + Loading systems… Caricamento sistemi… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Pagina %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_ja.ts b/src/ui/translations/frontend_ja.ts index 565b7533..a23f12b3 100644 --- a/src/ui/translations/frontend_ja.ts +++ b/src/ui/translations/frontend_ja.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 切断済み - + Reconnecting… 再接続中… - + Connecting… 接続中… - + Core error Core エラー - + Paused %1/%2 - + Paused 一時停止中 - + Opt… - + Optimizing 最適化中 - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried スクレイピング中 %1/%2 - %3 - + Indexing %1/%2 インデックス作成中 %1/%2 - + Idx %1/%2 - + Indexing… インデックス作成中… @@ -324,17 +324,17 @@ Français - Wilfried スクレイピングを一時停止 - + Scraping %1/%2 スクレイピング中 %1/%2 - + Scr %1/%2 - + Scraping… スクレイピング中… @@ -389,12 +389,12 @@ Français - Wilfried - + No favorites yet お気に入りはまだありません - + Loading favorite systems… @@ -417,7 +417,7 @@ Français - Wilfried お気に入りを読み込み中… - + %n favorite(s) @@ -431,54 +431,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - 初回セットアップ + 初回セットアップ - Indexing paused - インデックス作成を一時停止 + インデックス作成を一時停止 - Optimizing database - almost done - データベースを最適化中 - もうすぐ完了 - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + データベースを最適化中 - もうすぐ完了 - Step %1 of %2 - %3 - ステップ %1 / %2 - %3 + ステップ %1 / %2 - %3 - Step %1 of %2 - ステップ %1 / %2 + ステップ %1 / %2 - Preparing… - 準備中… + 準備中… - Done. %1 files indexed. - 完了。%1 ファイルをインデックスしました。 + 完了。%1 ファイルをインデックスしました。 - Cancel - キャンセル + キャンセル - Start scan - スキャン開始 + スキャン開始 @@ -497,34 +483,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 ファイル - + Loading game… - - + + %1 / %2 - + Loading games… ゲームを読み込み中… - + Loading more… さらに読み込み中… - + No games in this system このシステムにゲームはありません @@ -557,27 +543,27 @@ Français - Wilfried - + Resume - + Favorites お気に入り - + Recently Played 最近プレイしたゲーム - + Update - + Settings & Utilities @@ -586,7 +572,7 @@ Français - Wilfried 設定 - + No systems available. Run Update media database from Settings. 利用可能なシステムがありません。設定からメディアデータベースの更新を実行してください。 @@ -599,12 +585,12 @@ Français - Wilfried - + Loading… 読み込み中… - + No sections @@ -653,227 +639,231 @@ Français - Wilfried Main - + Launch core コアを起動 - - + + Change launcher - + Remove from favorites お気に入りから削除 - + Add to favorites お気に入りに追加 - + Write to NFC token NFC トークンに書き込む - QR code - QR コード + QR コード - - + + Launch game ゲームを起動 - + Go to... - - - + + + View - + Random favorite - + Loading systems… システムを読み込み中… - + Loading favorites… お気に入りを読み込み中… - + Loading games… ゲームを読み込み中… - - + + Update media database メディアデータベースを更新 - - + + Scrape metadata メタデータをスクレイピング - - + + Unhide - - + + Hide - - - + + + Default デフォルト - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites お気に入り - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 再試行 - + Cancel キャンセル - + Loading game… - + Loading recently played… 最近プレイしたゲームを読み込み中… - + Loading settings… - + Loading… 読み込み中… @@ -881,179 +871,176 @@ Français - Wilfried MainLayout - + Writing failed 書き込み失敗 - + Put a writable card near the reader 書き込み可能なカードをリーダーに近づけてください - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 決定 - + Random game - + No matching games found. - + Are you sure you want to exit? 本当に終了しますか? - - + + Select 選択 - - - - - + + + + Close 閉じる - - - - + + + Cancel キャンセル - + Done 完了 - + I understand 了解しました - + Adjust - + Save - Start - 開始 + 開始 - + Scroll スクロール + - - - + + View - - - - - - - - + + + + + + + + Move 移動 - + Zaparoo Frontend - - + + Favorites お気に入り - + Recently Played 最近プレイしたゲーム - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open 開く - + Quit 終了 - - - - + + + + + - - - - - - - - + + + + + + + Back 戻る @@ -1062,28 +1049,28 @@ Français - Wilfried ページ - - - - + + + + Options オプション - - - - + + + + Retry 再試行 - + Change 変更 - + Toggle 切り替え @@ -1096,12 +1083,12 @@ Français - Wilfried 読み込み中… - + %1 entries %1 件 - + %1 / %2 @@ -1129,6 +1116,19 @@ Français - Wilfried キャンセル + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1168,26 +1168,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language 言語 - - + + Browsing layout ブラウジングレイアウト - + Mouse support マウスサポート - + Update media database メディアデータベースを更新 @@ -1196,47 +1196,47 @@ Français - Wilfried 一般 - - + + Orientation - - + + Button style ボタンスタイル - - + + Screensaver スクリーンセーバー - - + + Library ライブラリ - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata メタデータをスクレイピング - + Re-scrape existing @@ -1245,470 +1245,467 @@ Français - Wilfried 詳細設定 - + Debug logging デバッグログ - + Upload log file ログファイルをアップロード - + About / License 情報 / ライセンス - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing 最適化中 + - Paused 一時停止中 + - In progress 実行中 - + %1 indexed %1 件を索引化 - + %1 scraped %1 件取得済み - + Cancel キャンセル - + Start 開始 - + Upload アップロード - + Open 開く - + English 英語 - + Italian イタリア語 - + Spanish - + Basque - + German ドイツ語 - + Greek ギリシャ語 - + Japanese 日本語 - + Korean 韓国語 - + Dutch オランダ語 - + Romanian ルーマニア語 - + Slovak スロバキア語 - + Ukrainian ウクライナ語 - + Chinese (Simplified) 中国語(簡体字) - + Chinese (Traditional) - + Hebrew ヘブライ語 - + Arabic - + Hindi - + French - - - + + + Auto 自動 - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view 詳細リスト表示 - + Grid view グリッド表示 - + Full color - + Tinted - + Style B スタイル B - + Style C スタイル C - + Style D スタイル D - + Style A スタイル A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - デフォルト + デフォルト - + Off オフ - + 1 second (testing) 1秒(テスト) - + 1 minute 1分 - + 2 minutes 2分 - + 5 minutes 5分 - + 10 minutes 10分 - + 15 minutes 15分 - + 30 minutes 30分 - + %1 seconds %1秒 - - Resolution - 解像度 + 解像度 - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings 設定 - + No settings available on this platform このプラットフォームでは設定項目がありません @@ -1716,24 +1713,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 システム - - + + %1 / %2 - + No systems in this category このカテゴリにシステムはありません - + Loading systems… システムを読み込み中… @@ -1741,7 +1738,7 @@ Français - Wilfried Tile - + Hidden @@ -1749,9 +1746,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 ページ %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_ko.ts b/src/ui/translations/frontend_ko.ts index 9d67964f..48e14f74 100644 --- a/src/ui/translations/frontend_ko.ts +++ b/src/ui/translations/frontend_ko.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 연결 끊김 - + Reconnecting… 재연결 중… - + Connecting… 연결 중… - + Core error 코어 오류 - + Paused %1/%2 - + Paused 일시중지됨 - + Opt… - + Optimizing 최적화 중 - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried 인덱싱 %1/%2 - %3 - + Indexing %1/%2 인덱싱 %1/%2 - + Idx %1/%2 - + Indexing… 인덱싱 중… @@ -324,17 +324,17 @@ Français - Wilfried 메타데이터 수집 %1/%2 - %3 - + Scraping %1/%2 메타데이터 수집 %1/%2 - + Scr %1/%2 - + Scraping… 메타데이터 수집 중… @@ -389,12 +389,12 @@ Français - Wilfried - + No favorites yet 아직 즐겨찾기가 없습니다 - + Loading favorite systems… @@ -417,7 +417,7 @@ Français - Wilfried 즐겨찾기 불러오는 중… - + %n favorite(s) @@ -431,54 +431,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - 최초 설정 + 최초 설정 - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - Optimizing database - almost done - 데이터베이스 최적화 중 - 거의 완료됨 + 데이터베이스 최적화 중 - 거의 완료됨 - Indexing paused - 인덱싱 일시중지됨 + 인덱싱 일시중지됨 - Step %1 of %2 - %3 - %2 중 %1단계 - %3 + %2 중 %1단계 - %3 - Step %1 of %2 - %2 중 %1단계 + %2 중 %1단계 - Preparing… - 준비 중… + 준비 중… - Done. %1 files indexed. - 완료. 파일 %1개를 인덱싱했습니다. + 완료. 파일 %1개를 인덱싱했습니다. - Cancel - 취소 + 취소 - Start scan - 스캔 시작 + 스캔 시작 @@ -497,34 +483,34 @@ Français - Wilfried GamesScreen - - + + %1 files 파일 %1개 - + Loading game… - - + + %1 / %2 - + No games in this system 이 시스템에는 게임이 없습니다 - + Loading games… 게임 불러오는 중… - + Loading more… 더 불러오는 중… @@ -557,27 +543,27 @@ Français - Wilfried - + Resume - + Favorites 즐겨찾기 - + Recently Played 최근 플레이 - + Update - + Settings & Utilities @@ -586,7 +572,7 @@ Français - Wilfried 설정 - + No systems available. Run Update media database from Settings. 사용 가능한 시스템이 없습니다. 설정에서 미디어 데이터베이스 업데이트를 실행하세요. @@ -599,12 +585,12 @@ Français - Wilfried - + Loading… 불러오는 중… - + No sections @@ -653,227 +639,231 @@ Français - Wilfried Main - + Launch core 코어 실행 - - + + Change launcher - - + + Update media database 미디어 데이터베이스 업데이트 - - + + Scrape metadata 메타데이터 수집 - - + + Unhide - - + + Hide - - + + Launch game 게임 실행 - + Remove from favorites 즐겨찾기에서 제거 - + Add to favorites 즐겨찾기에 추가 - + Write to NFC token NFC 토큰에 쓰기 - QR code - QR 코드 + QR 코드 - - - + + + Default 기본값 - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites 즐겨찾기 - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 다시 시도 - + Cancel 취소 - + Loading systems… 시스템 불러오는 중… - + Loading games… 게임 불러오는 중… - + Loading game… - + Loading favorites… 즐겨찾기 불러오는 중… - + Loading recently played… 최근 플레이 불러오는 중… - + Loading settings… - + Loading… 불러오는 중… @@ -881,182 +871,179 @@ Français - Wilfried MainLayout - + Writing failed 쓰기 실패 - + Put a writable card near the reader 기록 가능한 카드를 리더 근처에 놓으세요 - + Zaparoo Frontend - - + + Favorites 즐겨찾기 - + Recently Played 최근 플레이 - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 확인 - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? 정말 종료하시겠습니까? - - - - - - - - + + + + + + + + Move 이동 - - + + Select 선택 - - - - - + + + + Close 닫기 - - - - + + + Cancel 취소 - + Done 완료 - - - - + + + + Retry 다시 시도 - + I understand 이해했습니다 - + Adjust - + Save - Start - 시작 + 시작 - - - - - + + + + + Open 열기 - + Quit 종료 - - - - + + + + + - - - - - - - - + + + + + + + Back 뒤로 + - - - + + View @@ -1065,25 +1052,25 @@ Français - Wilfried 페이지 - - - - + + + + Options 옵션 - + Change 변경 - + Toggle 전환 - + Scroll 스크롤 @@ -1096,12 +1083,12 @@ Français - Wilfried 불러오는 중… - + %1 entries 항목 %1개 - + %1 / %2 @@ -1129,6 +1116,19 @@ Français - Wilfried 취소 + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1172,66 +1172,66 @@ Français - Wilfried 일반 - - - - + + + + Language 언어 - - + + Orientation - - + + Browsing layout 탐색 레이아웃 - - + + Button style 버튼 스타일 - - + + Screensaver 화면 보호기 - - + + Library 라이브러리 - + Discover arcade alternate versions - - + + Preferred artwork - + Update media database 미디어 데이터베이스 업데이트 - + Scrape metadata 메타데이터 수집 - + Re-scrape existing @@ -1240,475 +1240,472 @@ Français - Wilfried 고급 - + Mouse support 마우스 지원 - + Debug logging 디버그 로깅 - + Upload log file 로그 파일 업로드 - + About / License 정보 / 라이선스 - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing 최적화 중 + - Paused 일시중지됨 + - In progress 진행 중 - + %1 indexed %1개 인덱싱됨 - + %1 scraped %1개 수집됨 - + Cancel 취소 - + Start 시작 - + Upload 업로드 - + Open 열기 - Default - 기본값 + 기본값 - + English 영어 - + Italian 이탈리아어 - + Spanish - + Basque - + German 독일어 - + Greek 그리스어 - + Japanese 일본어 - + Korean 한국어 - + Dutch 네덜란드어 - + Romanian 루마니아어 - + Slovak 슬로바키아어 - + Ukrainian 우크라이나어 - + Chinese (Simplified) 중국어(간체) - + Chinese (Traditional) - + Hebrew 히브리어 - + Arabic - + Hindi - + French - - - + + + Auto 자동 - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view 자세한 목록 보기 - + Grid view 그리드 보기 - + Full color - + Tinted - + Style B 스타일 B - + Style C 스타일 C - + Style D 스타일 D - + Style A 스타일 A - + Off - + 1 second (testing) 1초 (테스트용) - + 1 minute 1분 - + 2 minutes 2분 - + 5 minutes 5분 - + 10 minutes 10분 - + 15 minutes 15분 - + 30 minutes 30분 - + %1 seconds %1초 - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - - Resolution - 해상도 + 해상도 - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings 설정 - + No settings available on this platform 이 플랫폼에서는 설정을 사용할 수 없습니다 @@ -1716,24 +1713,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems 시스템 %1개 - - + + %1 / %2 - + No systems in this category 이 카테고리에는 시스템이 없습니다 - + Loading systems… 시스템 불러오는 중… @@ -1741,7 +1738,7 @@ Français - Wilfried Tile - + Hidden @@ -1749,9 +1746,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 페이지 %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_nl.ts b/src/ui/translations/frontend_nl.ts index 3efd83f7..97a0677e 100644 --- a/src/ui/translations/frontend_nl.ts +++ b/src/ui/translations/frontend_nl.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Verbroken - + Reconnecting… Opnieuw verbinden… - + Connecting… Verbinden… - + Core error Core-fout - + Paused %1/%2 - + Paused Gepauzeerd - + Opt… - + Optimizing Optimaliseren - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexering %1/%2 - + Idx %1/%2 - + Indexing… Indexering… @@ -324,17 +324,17 @@ Français - Wilfried Scraping gepauzeerd - + Scraping %1/%2 Scrapen %1/%2 - + Scr %1/%2 - + Scraping… Bezig met scrapen… @@ -391,12 +391,12 @@ Français - Wilfried - + No favorites yet Nog geen favorieten - + Loading favorite systems… @@ -419,7 +419,7 @@ Français - Wilfried Favorieten laden… - + %n favorite(s) @@ -434,54 +434,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Eerste installatie + Eerste installatie - Indexing paused - Indexering gepauzeerd + Indexering gepauzeerd - Optimizing database - almost done - Database optimaliseren – bijna klaar - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Database optimaliseren – bijna klaar - Step %1 of %2 - %3 - Stap %1 van %2 – %3 + Stap %1 van %2 – %3 - Step %1 of %2 - Stap %1 van %2 + Stap %1 van %2 - Preparing… - Voorbereiden… + Voorbereiden… - Done. %1 files indexed. - Klaar. %1 bestanden geïndexeerd. + Klaar. %1 bestanden geïndexeerd. - Cancel - Annuleren + Annuleren - Start scan - Scan starten + Scan starten @@ -500,34 +486,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 bestanden - + Loading game… - - + + %1 / %2 - + Loading games… Games laden… - + Loading more… Meer laden… - + No games in this system Geen games in dit systeem @@ -560,27 +546,27 @@ Français - Wilfried - + Resume - + Favorites Favorieten - + Recently Played Recent gespeeld - + Update - + Settings & Utilities @@ -589,7 +575,7 @@ Français - Wilfried Instellingen - + No systems available. Run Update media database from Settings. Geen systemen beschikbaar. Voer Database bijwerken uit via Instellingen. @@ -602,12 +588,12 @@ Français - Wilfried - + Loading… Laden… - + No sections @@ -656,227 +642,231 @@ Français - Wilfried Main - + Launch core Core starten - - + + Change launcher - + Remove from favorites Uit favorieten verwijderen - + Add to favorites Aan favorieten toevoegen - + Write to NFC token Naar NFC-token schrijven - QR code - QR-code + QR-code - - + + Launch game Game starten - + Go to... - - - + + + View - + Random favorite - + Loading systems… Systemen laden… - + Loading favorites… Favorieten laden… - + Loading games… Games laden… - - + + Update media database Mediadatabase bijwerken - - + + Scrape metadata Metadata ophalen - - + + Unhide - - + + Hide - - - + + + Default Standaard - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Favorieten - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Opnieuw proberen - + Cancel Annuleren - + Loading game… - + Loading recently played… Recent gespeeld laden… - + Loading settings… - + Loading… Laden… @@ -884,179 +874,176 @@ Français - Wilfried MainLayout - + Writing failed Schrijven mislukt - + Put a writable card near the reader Houd een beschrijfbare kaart bij de lezer - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Akkoord - + Random game - + No matching games found. - + Are you sure you want to exit? Weet u zeker dat u wilt afsluiten? - - + + Select Selecteren - - - - - + + + + Close Sluiten - - - - + + + Cancel Annuleren - + Done Klaar - + I understand Ik begrijp het - + Adjust - + Save - Start - Starten + Starten - + Scroll Scrollen + - - - + + View - - - - - - - - + + + + + + + + Move Bewegen - + Zaparoo Frontend - - + + Favorites Favorieten - + Recently Played Recent gespeeld - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Openen - + Quit Afsluiten - - - - + + + + + - - - - - - - - + + + + + + + Back Terug @@ -1065,28 +1052,28 @@ Français - Wilfried Pagina - - - - + + + + Options Opties - - - - + + + + Retry Opnieuw proberen - + Change Wijzigen - + Toggle Schakelen @@ -1099,12 +1086,12 @@ Français - Wilfried Laden… - + %1 entries %1 items - + %1 / %2 @@ -1132,6 +1119,19 @@ Français - Wilfried Annuleren + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1171,26 +1171,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Taal - - + + Browsing layout Bladerlayout - + Mouse support Muisondersteuning - + Update media database Mediadatabase bijwerken @@ -1199,47 +1199,47 @@ Français - Wilfried Algemeen - - + + Orientation - - + + Button style Knopstijl - - + + Screensaver Schermbeveiliging - - + + Library Bibliotheek - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Metadata ophalen - + Re-scrape existing @@ -1248,470 +1248,467 @@ Français - Wilfried Geavanceerd - + Debug logging Debug-logging - + Upload log file Logbestand uploaden - + About / License Over / Licentie - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Optimaliseren + - Paused Gepauzeerd + - In progress Bezig - + %1 indexed %1 geïndexeerd - + %1 scraped %1 opgehaald - + Cancel Annuleren - + Start Starten - + Upload Uploaden - + Open Openen - + English Engels - + Italian Italiaans - + Spanish - + Basque - + German Duits - + Greek Grieks - + Japanese Japans - + Korean Koreaans - + Dutch Nederlands - + Romanian Roemeens - + Slovak Slowaaks - + Ukrainian Oekraïens - + Chinese (Simplified) Chinees (vereenvoudigd) - + Chinese (Traditional) - + Hebrew Hebreeuws - + Arabic - + Hindi - + French - - - + + + Auto Automatisch - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Gedetailleerde lijstweergave - + Grid view Rasterweergave - + Full color - + Tinted - + Style B Stijl B - + Style C Stijl C - + Style D Stijl D - + Style A Stijl A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Standaard + Standaard - + Off Uit - + 1 second (testing) 1 seconde (test) - + 1 minute 1 minuut - + 2 minutes 2 minuten - + 5 minutes 5 minuten - + 10 minutes 10 minuten - + 15 minutes 15 minuten - + 30 minutes 30 minuten - + %1 seconds %1 seconden - - Resolution - Resolutie + Resolutie - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Instellingen - + No settings available on this platform Geen instellingen beschikbaar op dit platform @@ -1719,24 +1716,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 systemen - - + + %1 / %2 - + No systems in this category Geen systemen in deze categorie - + Loading systems… Systemen laden… @@ -1744,7 +1741,7 @@ Français - Wilfried Tile - + Hidden @@ -1752,9 +1749,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Pagina %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_ro.ts b/src/ui/translations/frontend_ro.ts index a03425f3..5bab3a96 100644 --- a/src/ui/translations/frontend_ro.ts +++ b/src/ui/translations/frontend_ro.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Deconectat - + Reconnecting… Reconectare… - + Connecting… Conectare… - + Core error Eroare Core - + Paused %1/%2 - + Paused În pauză - + Opt… - + Optimizing Optimizare - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexare %1/%2 - + Idx %1/%2 - + Indexing… Indexare… @@ -324,17 +324,17 @@ Français - Wilfried Scraping în pauză - + Scraping %1/%2 Preluare %1/%2 - + Scr %1/%2 - + Scraping… Se preiau datele… @@ -393,12 +393,12 @@ Français - Wilfried - + No favorites yet Nicio intrare la favorite - + Loading favorite systems… @@ -421,7 +421,7 @@ Français - Wilfried Se încarcă favoritele… - + %n favorite(s) @@ -437,54 +437,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Configurare inițială + Configurare inițială - Indexing paused - Indexare în pauză + Indexare în pauză - Optimizing database - almost done - Optimizare bază de date – aproape gata - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Optimizare bază de date – aproape gata - Step %1 of %2 - %3 - Pasul %1 din %2 – %3 + Pasul %1 din %2 – %3 - Step %1 of %2 - Pasul %1 din %2 + Pasul %1 din %2 - Preparing… - Pregătire… + Pregătire… - Done. %1 files indexed. - Gata. %1 fișiere indexate. + Gata. %1 fișiere indexate. - Cancel - Anulare + Anulare - Start scan - Pornire scanare + Pornire scanare @@ -503,34 +489,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 fișiere - + Loading game… - - + + %1 / %2 - + Loading games… Se încarcă jocurile… - + Loading more… Se încarcă mai multe… - + No games in this system Niciun joc în acest sistem @@ -563,27 +549,27 @@ Français - Wilfried - + Resume - + Favorites Favorite - + Recently Played Recent Jucate - + Update - + Settings & Utilities @@ -592,7 +578,7 @@ Français - Wilfried Setări - + No systems available. Run Update media database from Settings. Niciun sistem disponibil. Rulați Actualizare bază de date din Setări. @@ -605,12 +591,12 @@ Français - Wilfried - + Loading… Se încarcă… - + No sections @@ -659,227 +645,231 @@ Français - Wilfried Main - + Launch core Lansare core - - + + Change launcher - + Remove from favorites Elimină din favorite - + Add to favorites Adaugă la favorite - + Write to NFC token Scriere pe token NFC - QR code - Cod QR + Cod QR - - + + Launch game Lansare joc - + Go to... - - - + + + View - + Random favorite - + Loading systems… Se încarcă sistemele… - + Loading favorites… Se încarcă favoritele… - + Loading games… Se încarcă jocurile… - - + + Update media database Actualizare bază de date media - - + + Scrape metadata Extragere metadate - - + + Unhide - - + + Hide - - - + + + Default Implicit - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Favorite - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Reîncercare - + Cancel Anulare - + Loading game… - + Loading recently played… Se încarcă recent jucatele… - + Loading settings… - + Loading… Se încarcă… @@ -887,179 +877,176 @@ Français - Wilfried MainLayout - + Writing failed Scriere eșuată - + Put a writable card near the reader Apropiați un card inscriptibil de cititor - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK În regulă - + Random game - + No matching games found. - + Are you sure you want to exit? Sigur doriți să ieșiți? - - + + Select Selectare - - - - - + + + + Close Închidere - - - - + + + Cancel Anulare - + Done Gata - + I understand Am înțeles - + Adjust - + Save - Start - Pornire + Pornire - + Scroll Derulare + - - - + + View - - - - - - - - + + + + + + + + Move Mutare - + Zaparoo Frontend - - + + Favorites Favorite - + Recently Played Recent Jucate - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Deschidere - + Quit Ieșire - - - - + + + + + - - - - - - - - + + + + + + + Back Înapoi @@ -1068,28 +1055,28 @@ Français - Wilfried Pagină - - - - + + + + Options Opțiuni - - - - + + + + Retry Reîncercare - + Change Schimbare - + Toggle Comutare @@ -1102,12 +1089,12 @@ Français - Wilfried Se încarcă… - + %1 entries %1 intrări - + %1 / %2 @@ -1135,6 +1122,19 @@ Français - Wilfried Anulare + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1174,26 +1174,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Limbă - - + + Browsing layout Aspect navigare - + Mouse support Suport mouse - + Update media database Actualizare bază de date media @@ -1202,47 +1202,47 @@ Français - Wilfried Generale - - + + Orientation - - + + Button style Stil butoane - - + + Screensaver Economizor de ecran - - + + Library Bibliotecă - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Extragere metadate - + Re-scrape existing @@ -1251,470 +1251,467 @@ Français - Wilfried Avansat - + Debug logging Jurnal depanare - + Upload log file Încărcare fișier jurnal - + About / License Despre / Licență - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Optimizare + - Paused În pauză + - In progress În curs - + %1 indexed %1 indexate - + %1 scraped %1 extrase - + Cancel Anulare - + Start Pornire - + Upload Încărcare - + Open Deschidere - + English Engleză - + Italian Italiană - + Spanish - + Basque - + German Germană - + Greek Greacă - + Japanese Japoneză - + Korean Coreeană - + Dutch Olandeză - + Romanian Română - + Slovak Slovacă - + Ukrainian Ucraineană - + Chinese (Simplified) Chineză (simplificată) - + Chinese (Traditional) - + Hebrew Ebraică - + Arabic - + Hindi - + French - - - + + + Auto Automat - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Vizualizare listă detaliată - + Grid view Vizualizare grilă - + Full color - + Tinted - + Style B Stil B - + Style C Stil C - + Style D Stil D - + Style A Stil A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Implicit + Implicit - + Off Dezactivat - + 1 second (testing) 1 secundă (test) - + 1 minute 1 minut - + 2 minutes 2 minute - + 5 minutes 5 minute - + 10 minutes 10 minute - + 15 minutes 15 minute - + 30 minutes 30 de minute - + %1 seconds %1 secunde - - Resolution - Rezoluție + Rezoluție - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Setări - + No settings available on this platform Nicio setare disponibilă pe această platformă @@ -1722,24 +1719,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 sisteme - - + + %1 / %2 - + No systems in this category Niciun sistem în această categorie - + Loading systems… Se încarcă sistemele… @@ -1747,7 +1744,7 @@ Français - Wilfried Tile - + Hidden @@ -1755,9 +1752,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Pagina %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_sk.ts b/src/ui/translations/frontend_sk.ts index 176022f5..beb78093 100644 --- a/src/ui/translations/frontend_sk.ts +++ b/src/ui/translations/frontend_sk.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Odpojené - + Reconnecting… Opätovné pripojenie… - + Connecting… Pripájanie… - + Core error Chyba Core - + Paused %1/%2 - + Paused Pozastavené - + Opt… - + Optimizing Optimalizácia - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexovanie %1/%2 - + Idx %1/%2 - + Indexing… Indexovanie… @@ -324,17 +324,17 @@ Français - Wilfried Scraping pozastavený - + Scraping %1/%2 Sťahovanie %1/%2 - + Scr %1/%2 - + Scraping… Prebieha sťahovanie… @@ -393,12 +393,12 @@ Français - Wilfried - + No favorites yet Zatiaľ žiadne obľúbené - + Loading favorite systems… @@ -421,7 +421,7 @@ Français - Wilfried Načítanie obľúbených… - + %n favorite(s) @@ -437,54 +437,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Prvé nastavenie + Prvé nastavenie - Indexing paused - Indexovanie pozastavené + Indexovanie pozastavené - Optimizing database - almost done - Optimalizácia databázy – takmer hotovo - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Optimalizácia databázy – takmer hotovo - Step %1 of %2 - %3 - Krok %1 z %2 – %3 + Krok %1 z %2 – %3 - Step %1 of %2 - Krok %1 z %2 + Krok %1 z %2 - Preparing… - Príprava… + Príprava… - Done. %1 files indexed. - Hotovo. Indexovaných %1 súborov. + Hotovo. Indexovaných %1 súborov. - Cancel - Zrušiť + Zrušiť - Start scan - Spustiť skenovanie + Spustiť skenovanie @@ -503,34 +489,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 súborov - + Loading game… - - + + %1 / %2 - + Loading games… Načítanie hier… - + Loading more… Načítava sa viac… - + No games in this system V tomto systéme nie sú žiadne hry @@ -563,27 +549,27 @@ Français - Wilfried - + Resume - + Favorites Obľúbené - + Recently Played Nedávno hrané - + Update - + Settings & Utilities @@ -592,7 +578,7 @@ Français - Wilfried Nastavenia - + No systems available. Run Update media database from Settings. Žiadne systémy nie sú dostupné. Spustite Aktualizovať databázu médií v Nastaveniach. @@ -605,12 +591,12 @@ Français - Wilfried - + Loading… Načítanie… - + No sections @@ -659,227 +645,231 @@ Français - Wilfried Main - + Launch core Spustiť core - - + + Change launcher - + Remove from favorites Odstrániť z obľúbených - + Add to favorites Pridať do obľúbených - + Write to NFC token Zapísať na NFC token - QR code - QR kód + QR kód - - + + Launch game Spustiť hru - + Go to... - - - + + + View - + Random favorite - + Loading systems… Načítanie systémov… - + Loading favorites… Načítanie obľúbených… - + Loading games… Načítanie hier… - - + + Update media database Aktualizovať databázu médií - - + + Scrape metadata Získať metadáta - - + + Unhide - - + + Hide - - - + + + Default Predvolené - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Obľúbené - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Skúsiť znova - + Cancel Zrušiť - + Loading game… - + Loading recently played… Načítanie nedávno hraných… - + Loading settings… - + Loading… Načítanie… @@ -887,179 +877,176 @@ Français - Wilfried MainLayout - + Writing failed Zápis zlyhal - + Put a writable card near the reader Priložte zapisovateľnú kartu k čítačke - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK V poriadku - + Random game - + No matching games found. - + Are you sure you want to exit? Naozaj chcete ukončiť? - - + + Select Vybrať - - - - - + + + + Close Zavrieť - - - - + + + Cancel Zrušiť - + Done Hotovo - + I understand Rozumiem - + Adjust - + Save - Start - Spustiť + Spustiť - + Scroll Posúvať + - - - + + View - - - - - - - - + + + + + + + + Move Presunúť - + Zaparoo Frontend - - + + Favorites Obľúbené - + Recently Played Nedávno hrané - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Otvoriť - + Quit Ukončiť - - - - + + + + + - - - - - - - - + + + + + + + Back Späť @@ -1068,28 +1055,28 @@ Français - Wilfried Stránka - - - - + + + + Options Možnosti - - - - + + + + Retry Skúsiť znova - + Change Zmeniť - + Toggle Prepínať @@ -1102,12 +1089,12 @@ Français - Wilfried Načítanie… - + %1 entries %1 položiek - + %1 / %2 @@ -1135,6 +1122,19 @@ Français - Wilfried Zrušiť + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1174,26 +1174,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Jazyk - - + + Browsing layout Rozloženie prehliadania - + Mouse support Podpora myši - + Update media database Aktualizovať databázu médií @@ -1202,47 +1202,47 @@ Français - Wilfried Všeobecné - - + + Orientation - - + + Button style Štýl tlačidiel - - + + Screensaver Šetrič obrazovky - - + + Library Knižnica - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Získať metadáta - + Re-scrape existing @@ -1251,470 +1251,467 @@ Français - Wilfried Rozšírené - + Debug logging Ladiace záznamy - + Upload log file Nahrať súbor protokolu - + About / License O aplikácii / Licencia - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Optimalizácia + - Paused Pozastavené + - In progress Prebieha - + %1 indexed %1 indexovaných - + %1 scraped %1 zozbieraných - + Cancel Zrušiť - + Start Spustiť - + Upload Nahrať - + Open Otvoriť - + English Angličtina - + Italian Taliančina - + Spanish - + Basque - + German Nemčina - + Greek Gréčtina - + Japanese Japončina - + Korean Kórejčina - + Dutch Holandčina - + Romanian Rumunčina - + Slovak Slovenčina - + Ukrainian Ukrajinčina - + Chinese (Simplified) Čínština (zjednodušená) - + Chinese (Traditional) - + Hebrew Hebrejčina - + Arabic - + Hindi - + French - - - + + + Auto Automaticky - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Podrobné zobrazenie zoznamu - + Grid view Zobrazenie mriežky - + Full color - + Tinted - + Style B Štýl B - + Style C Štýl C - + Style D Štýl D - + Style A Štýl A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Predvolené + Predvolené - + Off Vypnuté - + 1 second (testing) 1 sekunda (test) - + 1 minute 1 minúta - + 2 minutes 2 minúty - + 5 minutes 5 minút - + 10 minutes 10 minút - + 15 minutes 15 minút - + 30 minutes 30 minút - + %1 seconds %1 sekúnd - - Resolution - Rozlíšenie + Rozlíšenie - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Nastavenia - + No settings available on this platform Na tejto platforme nie sú dostupné žiadne nastavenia @@ -1722,24 +1719,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 systémov - - + + %1 / %2 - + No systems in this category V tejto kategórii nie sú žiadne systémy - + Loading systems… Načítanie systémov… @@ -1747,7 +1744,7 @@ Français - Wilfried Tile - + Hidden @@ -1755,9 +1752,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Stránka %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_uk.ts b/src/ui/translations/frontend_uk.ts index 494cc869..fb94a659 100644 --- a/src/ui/translations/frontend_uk.ts +++ b/src/ui/translations/frontend_uk.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Відключено - + Reconnecting… Повторне підключення… - + Connecting… Підключення… - + Core error Помилка Core - + Paused %1/%2 - + Paused Призупинено - + Opt… - + Optimizing Оптимізація - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Скрейпінг %1/%2 – %3 - + Indexing %1/%2 Індексування %1/%2 - + Idx %1/%2 - + Indexing… Індексування… @@ -324,17 +324,17 @@ Français - Wilfried Скрейпінг призупинено - + Scraping %1/%2 Скрейпінг %1/%2 - + Scr %1/%2 - + Scraping… Скрейпінг… @@ -393,12 +393,12 @@ Français - Wilfried - + No favorites yet Поки немає вибраного - + Loading favorite systems… @@ -421,7 +421,7 @@ Français - Wilfried Завантаження вибраного… - + %n favorite(s) @@ -437,54 +437,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - Початкове налаштування + Початкове налаштування - Indexing paused - Індексування призупинено + Індексування призупинено - Optimizing database - almost done - Оптимізація бази даних – майже готово - - - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - + Оптимізація бази даних – майже готово - Step %1 of %2 - %3 - Крок %1 з %2 – %3 + Крок %1 з %2 – %3 - Step %1 of %2 - Крок %1 з %2 + Крок %1 з %2 - Preparing… - Підготовка… + Підготовка… - Done. %1 files indexed. - Готово. Проіндексовано %1 файлів. + Готово. Проіндексовано %1 файлів. - Cancel - Скасувати + Скасувати - Start scan - Почати сканування + Почати сканування @@ -503,34 +489,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 файлів - + Loading game… - - + + %1 / %2 - + Loading games… Завантаження ігор… - + Loading more… Завантаження ще… - + No games in this system У цій системі немає ігор @@ -563,27 +549,27 @@ Français - Wilfried - + Resume - + Favorites Вибране - + Recently Played Нещодавно зіграні - + Update - + Settings & Utilities @@ -592,7 +578,7 @@ Français - Wilfried Налаштування - + No systems available. Run Update media database from Settings. Немає доступних систем. Запустіть Оновлення бази даних з Налаштувань. @@ -605,12 +591,12 @@ Français - Wilfried - + Loading… Завантаження… - + No sections @@ -659,227 +645,231 @@ Français - Wilfried Main - + Launch core Запустити core - - + + Change launcher - + Remove from favorites Видалити з вибраного - + Add to favorites Додати до вибраного - + Write to NFC token Записати на NFC-токен - QR code - QR-код + QR-код - - + + Launch game Запустити гру - + Go to... - - - + + + View - + Random favorite - + Loading systems… Завантаження систем… - + Loading favorites… Завантаження вибраного… - + Loading games… Завантаження ігор… - - + + Update media database Оновити базу медіа - - + + Scrape metadata Отримати метадані - - + + Unhide - - + + Hide - - - + + + Default Типовий - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Favorites Вибране - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Повторити - + Cancel Скасувати - + Loading game… - + Loading recently played… Завантаження нещодавніх… - + Loading settings… - + Loading… Завантаження… @@ -887,179 +877,176 @@ Français - Wilfried MainLayout - + Writing failed Помилка запису - + Put a writable card near the reader Прикладіть картку для запису до зчитувача - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Гаразд - + Random game - + No matching games found. - + Are you sure you want to exit? Ви впевнені, що хочете вийти? - - + + Select Вибрати - - - - - + + + + Close Закрити - - - - + + + Cancel Скасувати - + Done Готово - + I understand Я розумію - + Adjust - + Save - Start - Почати + Почати - + Scroll Прокрутка + - - - + + View - - - - - - - - + + + + + + + + Move Переміщення - + Zaparoo Frontend - - + + Favorites Вибране - + Recently Played Нещодавно зіграні - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Відкрити - + Quit Вийти - - - - + + + + + - - - - - - - - + + + + + + + Back Назад @@ -1068,28 +1055,28 @@ Français - Wilfried Сторінка - - - - + + + + Options Параметри - - - - + + + + Retry Повторити - + Change Змінити - + Toggle Перемкнути @@ -1102,12 +1089,12 @@ Français - Wilfried Завантаження… - + %1 entries %1 записів - + %1 / %2 @@ -1135,6 +1122,19 @@ Français - Wilfried Скасувати + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1174,26 +1174,26 @@ Français - Wilfried SettingsScreen - - - - + + + + Language Мова - - + + Browsing layout Режим перегляду - + Mouse support Підтримка миші - + Update media database Оновити базу медіа @@ -1202,47 +1202,47 @@ Français - Wilfried Загальні - - + + Orientation - - + + Button style Стиль кнопок - - + + Screensaver Заставка - - + + Library Бібліотека - + Discover arcade alternate versions - - + + Preferred artwork - + Scrape metadata Отримати метадані - + Re-scrape existing @@ -1251,470 +1251,467 @@ Français - Wilfried Розширені - + Debug logging Журнал відлагодження - + Upload log file Завантажити файл журналу - + About / License Про програму / Ліцензія - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing Оптимізація + - Paused Призупинено + - In progress Виконується - + %1 indexed %1 проіндексовано - + %1 scraped %1 зібрано - + Cancel Скасувати - + Start Почати - + Upload Вивантажити - + Open Відкрити - + English Англійська - + Italian Італійська - + Spanish - + Basque - + German Німецька - + Greek Грецька - + Japanese Японська - + Korean Корейська - + Dutch Нідерландська - + Romanian Румунська - + Slovak Словацька - + Ukrainian Українська - + Chinese (Simplified) Китайська (спрощена) - + Chinese (Traditional) - + Hebrew Іврит - + Arabic - + Hindi - + French - - - + + + Auto Автоматично - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view Детальний перегляд списку - + Grid view Перегляд сіткою - + Full color - + Tinted - + Style B Стиль B - + Style C Стиль C - + Style D Стиль D - + Style A Стиль A - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - Default - Типовий + Типовий - + Off Вимкнено - + 1 second (testing) 1 секунда (тест) - + 1 minute 1 хвилина - + 2 minutes 2 хвилини - + 5 minutes 5 хвилин - + 10 minutes 10 хвилин - + 15 minutes 15 хвилин - + 30 minutes 30 хвилин - + %1 seconds %1 секунд - - Resolution - Роздільна здатність + Роздільна здатність - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings Налаштування - + No settings available on this platform На цій платформі налаштування недоступні @@ -1722,24 +1719,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 систем - - + + %1 / %2 - + No systems in this category У цій категорії немає систем - + Loading systems… Завантаження систем… @@ -1747,7 +1744,7 @@ Français - Wilfried Tile - + Hidden @@ -1755,9 +1752,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 Сторінка %1 / %2 + + + Page %1 + + diff --git a/src/ui/translations/frontend_zh_CN.ts b/src/ui/translations/frontend_zh_CN.ts index 9551615f..a9e45e6d 100644 --- a/src/ui/translations/frontend_zh_CN.ts +++ b/src/ui/translations/frontend_zh_CN.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 已断开 - + Reconnecting… 正在重新连接… - + Connecting… 正在连接… - + Core error Core 错误 - + Paused %1/%2 - + Paused 已暂停 - + Opt… - + Optimizing 正在优化 - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried 正在索引 %1/%2 - %3 - + Indexing %1/%2 正在索引 %1/%2 - + Idx %1/%2 - + Indexing… 正在索引… @@ -324,17 +324,17 @@ Français - Wilfried 正在抓取 %1/%2 - %3 - + Scraping %1/%2 正在抓取 %1/%2 - + Scr %1/%2 - + Scraping… 正在抓取… @@ -389,12 +389,12 @@ Français - Wilfried - + No favorites yet 还没有收藏 - + Loading favorite systems… @@ -417,7 +417,7 @@ Français - Wilfried 正在加载收藏… - + %n favorite(s) @@ -431,54 +431,40 @@ Français - Wilfried FirstRunIndexModal - First-time setup - 首次设置 + 首次设置 - - Zaparoo needs to scan your games before you can use the frontend. This usually takes a few minutes. - - - - Optimizing database - almost done - 正在优化数据库 - 即将完成 + 正在优化数据库 - 即将完成 - Indexing paused - 索引已暂停 + 索引已暂停 - Step %1 of %2 - %3 - 第 %1 / %2 步 - %3 + 第 %1 / %2 步 - %3 - Step %1 of %2 - 第 %1 / %2 步 + 第 %1 / %2 步 - Preparing… - 正在准备… + 正在准备… - Done. %1 files indexed. - 完成。已索引 %1 个文件。 + 完成。已索引 %1 个文件。 - Cancel - 取消 + 取消 - Start scan - 开始扫描 + 开始扫描 @@ -497,34 +483,34 @@ Français - Wilfried GamesScreen - - + + %1 files %1 个文件 - + Loading game… - - + + %1 / %2 - + No games in this system 此系统中没有游戏 - + Loading games… 正在加载游戏… - + Loading more… 正在加载更多… @@ -557,27 +543,27 @@ Français - Wilfried - + Resume - + Favorites 收藏 - + Recently Played 最近游玩 - + Update - + Settings & Utilities @@ -586,7 +572,7 @@ Français - Wilfried 设置 - + No systems available. Run Update media database from Settings. 没有可用系统。请在“设置”中运行“更新媒体数据库”。 @@ -599,12 +585,12 @@ Français - Wilfried - + Loading… 正在加载… - + No sections @@ -653,227 +639,231 @@ Français - Wilfried Main - + Launch core 启动核心 - - + + Change launcher - - + + Update media database 更新媒体数据库 - - + + Scrape metadata 抓取元数据 - - + + Unhide - - + + Hide - - + + Launch game 启动游戏 - + Remove from favorites 从收藏中移除 - + Add to favorites 添加到收藏 - + Write to NFC token 写入 NFC 令牌 - QR code - 二维码 + 二维码 - - - + + + Default 默认 - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites 收藏 - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - - + + Write with QR code + + + + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 重试 - + Cancel 取消 - + Loading systems… 正在加载系统… - + Loading games… 正在加载游戏… - + Loading game… - + Loading favorites… 正在加载收藏… - + Loading recently played… 正在加载最近游玩… - + Loading settings… - + Loading… 正在加载… @@ -881,182 +871,179 @@ Français - Wilfried MainLayout - + Writing failed 写入失败 - + Put a writable card near the reader 将可写卡片放在读卡器附近 - + Zaparoo Frontend - - + + Favorites 收藏 - + Recently Played 最近游玩 - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 确定 - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? 确定要退出吗? - - - - - - - - + + + + + + + + Move 移动 - - + + Select 选择 - - - - - + + + + Close 关闭 - - - - + + + Cancel 取消 - + Done 完成 - - - - + + + + Retry 重试 - + I understand 我明白了 - + Adjust - + Save - Start - 开始 + 开始 - - - - - + + + + + Open 打开 - + Quit 退出 - - - - + + + + + - - - - - - - - + + + + + + + Back 返回 + - - - + + View @@ -1065,25 +1052,25 @@ Français - Wilfried 页面 - - - - + + + + Options 选项 - + Change 更改 - + Toggle 切换 - + Scroll 滚动 @@ -1096,12 +1083,12 @@ Français - Wilfried 正在加载… - + %1 entries %1 个条目 - + %1 / %2 @@ -1129,6 +1116,19 @@ Français - Wilfried 取消 + + QrCodeModal + + + Write with QR code + + + + + Scan this code with your phone to write this game to a Zaparoo token. + + + RecentsScreen @@ -1172,66 +1172,66 @@ Français - Wilfried 常规 - - - - + + + + Language 语言 - - + + Orientation - - + + Browsing layout 浏览布局 - - + + Button style 按钮样式 - - + + Screensaver 屏幕保护程序 - - + + Library 资料库 - + Discover arcade alternate versions - - + + Preferred artwork - + Update media database 更新媒体数据库 - + Scrape metadata 抓取元数据 - + Re-scrape existing @@ -1240,475 +1240,472 @@ Français - Wilfried 高级 - + Mouse support 鼠标支持 - + Debug logging 调试日志 - + Upload log file 上传日志文件 - + About / License 关于 / 许可 - - + + Clock format - - + + System names - - + + Browsing - + Analog video - + CRT mode - - + + Video standard - + Screen position - - + + System logos - + Show hidden items - + Show original filenames - + Reduce motion - + Optimizing 正在优化 + - Paused 已暂停 + - In progress 进行中 - + %1 indexed 已索引 %1 个 - + %1 scraped 已抓取 %1 个 - + Cancel 取消 - + Start 开始 - + Upload 上传 - + Open 打开 - Default - 默认 + 默认 - + English 英语 - + Italian 意大利语 - + Spanish - + Basque - + German 德语 - + Greek 希腊语 - + Japanese 日语 - + Korean 韩语 - + Dutch 荷兰语 - + Romanian 罗马尼亚语 - + Slovak 斯洛伐克语 - + Ukrainian 乌克兰语 - + Chinese (Simplified) 简体中文 - + Chinese (Traditional) - + Hebrew 希伯来语 - + Arabic - + Hindi - + French - - - + + + Auto 自动 - + 12-hour - + 24-hour - + Americas - + Europe - + Japan - + Automatic - + Rotated CW - + Rotated CCW - + Horizontal - + Detailed list view 详细列表视图 - + Grid view 网格视图 - + Full color - + Tinted - + Style B 样式 B - + Style C 样式 C - + Style D 样式 D - + Style A 样式 A - + Off 关闭 - + 1 second (testing) 1 秒(测试) - + 1 minute 1 分钟 - + 2 minutes 2 分钟 - + 5 minutes 5 分钟 - + 10 minutes 10 分钟 - + 15 minutes 15 分钟 - + 30 minutes 30 分钟 - + %1 seconds %1 秒 - + PAL (50 Hz) - + 480i (60 Hz) - + NTSC (60 Hz) - + Loading settings… - - Resolution - 分辨率 + 分辨率 - - + + Display - - + + Controls - - + + Support - + Image - + Thumbnail - + Box art - + 3D box art - + Screenshot - + Wheel - + Title screen - + Map - + Marquee - + Fan art - + Box side - + Box back - + Settings 设置 - + No settings available on this platform 此平台上没有可用设置 @@ -1716,24 +1713,24 @@ Français - Wilfried SystemsScreen - - + + %1 systems %1 个系统 - - + + %1 / %2 - + No systems in this category 此类别中没有系统 - + Loading systems… 正在加载系统… @@ -1741,7 +1738,7 @@ Français - Wilfried Tile - + Hidden @@ -1749,9 +1746,14 @@ Français - Wilfried TopStatusStrip - + Page %1 / %2 第 %1 / %2 页 + + + Page %1 + + diff --git a/tests/ui/tst_letter_jump_modal.qml b/tests/ui/tst_letter_jump_modal.qml index b936bcdf..a326fa9f 100644 --- a/tests/ui/tst_letter_jump_modal.qml +++ b/tests/ui/tst_letter_jump_modal.qml @@ -75,12 +75,21 @@ TestCase { // ── nextIndex: pure 2D grid move ────────────────────────────────────────── - function test_next_index_left_right_clamps_within_bounds(): void { - // 8 cells, 4 columns. right advances; right at the end stays. + function test_next_index_left_right_wraps_within_each_row(): void { + // 8 cells, 4 columns. Horizontal movement wraps within the current + // row instead of leaking into the row above/below. compare(grid.nextIndex("right", 0, 8, 4), 1); - compare(grid.nextIndex("right", 7, 8, 4), 7); + compare(grid.nextIndex("right", 3, 8, 4), 0); + compare(grid.nextIndex("right", 7, 8, 4), 4); compare(grid.nextIndex("left", 3, 8, 4), 2); - compare(grid.nextIndex("left", 0, 8, 4), 0); + compare(grid.nextIndex("left", 0, 8, 4), 3); + compare(grid.nextIndex("left", 4, 8, 4), 7); + } + + function test_next_index_left_right_wraps_partial_final_row(): void { + // 6 cells, 4 columns: final row contains only indices 4 and 5. + compare(grid.nextIndex("right", 5, 6, 4), 4); + compare(grid.nextIndex("left", 4, 6, 4), 5); } function test_next_index_up_down_step_by_columns(): void { diff --git a/tests/ui/tst_list_picker_modal.qml b/tests/ui/tst_list_picker_modal.qml index 69c086ee..a00d655b 100644 --- a/tests/ui/tst_list_picker_modal.qml +++ b/tests/ui/tst_list_picker_modal.qml @@ -156,6 +156,13 @@ TestCase { compare(closeSpy.count, 1); } + function test_handle_action_page_menu_toggles_picker_closed(): void { + picker.entries = _entries(3); + picker.open = true; + picker.handleAction("page_menu"); + compare(closeSpy.count, 1); + } + function test_reopen_recomputes_initial_index(): void { // First open lands on a match. picker.entries = _entries(4); diff --git a/tests/ui/tst_navigation.qml b/tests/ui/tst_navigation.qml index 17db3578..aab90594 100644 --- a/tests/ui/tst_navigation.qml +++ b/tests/ui/tst_navigation.qml @@ -60,6 +60,13 @@ TestCase { main.settingsScreenRequested = true; main.activeScreen = main.screenHub; main.pendingTransition = ""; + main.systemsCoverRevealReady = true; + main.gamesCoverRevealReady = true; + main.gamesNavigationInputAt = 0; + main.gamesNavigationModelReadyAt = 0; + main.gamesNavigationAction = ""; + main.gamesScreen.lastNavigationInputAt = 0; + main._firstRunIndexStarted = false; tryCompare(main, "transitionCueVisible", false); // Hub focus is two rows now (categories + actions); reset both // axes so a prior test's row-jump doesn't leak into the next. @@ -91,6 +98,29 @@ TestCase { Browse.GamesModel.total_files = 0; } + function test_first_run_index_starts_only_from_authoritative_empty_state(): void { + compare(main._shouldStartFirstRunIndex(2, true, true, 0), true); + compare(main._shouldStartFirstRunIndex(1, true, true, 0), false); + compare(main._shouldStartFirstRunIndex(2, false, true, 0), false); + compare(main._shouldStartFirstRunIndex(2, true, false, 0), false); + compare(main._shouldStartFirstRunIndex(2, true, true, 1), false); + main._firstRunIndexStarted = true; + compare(main._shouldStartFirstRunIndex(2, true, true, 0), false); + } + + function test_catalog_polling_is_limited_to_system_membership_screens(): void { + main.activeScreen = main.screenHub; + compare(main._catalogRefreshScreenActive(), true); + main.activeScreen = main.screenSystems; + compare(main._catalogRefreshScreenActive(), true); + main.activeScreen = main.screenFavoriteSystems; + compare(main._catalogRefreshScreenActive(), true); + main.activeScreen = main.screenGames; + compare(main._catalogRefreshScreenActive(), false); + main.activeScreen = main.screenFavorites; + compare(main._catalogRefreshScreenActive(), false); + } + function test_initial_state_is_hub(): void { compare(main.activeScreen, main.screenHub); compare(main.hubScreen.visible, true); @@ -124,6 +154,103 @@ TestCase { compare(main.systemsScreen.visible, false); } + function test_help_bar_stays_stable_during_forward_transition(): void { + main.activeScreen = main.screenHub; + const before = JSON.stringify(main.helpEntries); + verify(before !== "[]"); + + main.pendingTransition = "systems"; + compare(JSON.stringify(main.helpEntries), before); + } + + function test_games_rapid_scroll_snapshot_crops_to_cell_area(): void { + const snapshot = findChild(main.gamesScreen, "rapidScrollSnapshot"); + const snapshotImage = findChild(main.gamesScreen, "rapidScrollSnapshotImage"); + verify(snapshot !== null); + verify(snapshotImage !== null); + compare(snapshot.x, main.gamesScreen.gamesGrid.x + main.gamesScreen.gamesGrid.leftInset); + compare(snapshot.y, main.gamesScreen.gamesGrid.y + main.gamesScreen.gamesGrid.topInset); + compare(snapshot.width, main.gamesScreen.gamesGrid._contentWidth); + compare(snapshot.height, main.gamesScreen.gamesGrid.rows * main.gamesScreen.gamesGrid.cellHeight + Math.max(0, main.gamesScreen.gamesGrid.rows - 1) * main.gamesScreen.gamesGrid.cellSpacingY); + compare(snapshotImage.sourceClipRect.x, main.gamesScreen.gamesGrid.leftInset); + compare(snapshotImage.sourceClipRect.y, main.gamesScreen.gamesGrid.topInset); + compare(snapshotImage.opacity, 0.28); + } + + function test_games_deep_page_restore_hides_page_one_until_selection_found(): void { + main.activeScreen = main.screenGames; + main._pendingGameRestorePath = "/saved/parent/page-three-game.zip"; + compare(main.gamesSelectionRestorePending, true); + compare(main.gamesScreen.optimisticLoading, true); + compare(main.gamesScreen._gateHide, true, "first loaded page must not paint during deep-page restore"); + main._pendingGameRestorePath = ""; + } + + function test_game_covers_wait_for_model_frame(): void { + main.activeScreen = main.screenGames; + main.gamesCoverRevealReady = false; + compare(main.gamesScreen.gamesGrid.coverRequestsEnabled, false); + + main.gamesCoverRevealReady = true; + compare(main.gamesScreen.gamesGrid.coverRequestsEnabled, true); + } + + function test_games_header_extracts_current_folder_name(): void { + compare(main.gamesScreen._folderNameForPath("/media/fat/games/SNES/RPGs"), "RPGs"); + compare(main.gamesScreen._folderNameForPath("/media/fat/games/SNES/RPGs/"), "RPGs"); + compare(main.gamesScreen._folderNameForPath("C:\\Games\\SNES\\RPGs"), "RPGs"); + compare(main.gamesScreen._folderNameForPath(""), ""); + } + + function test_folder_navigation_timing_uses_input_timestamp(): void { + const inputAt = Date.now() - 25; + main.gamesScreen.lastNavigationInputAt = inputAt; + + main._beginFolderNavigationTiming("back"); + + compare(main.gamesNavigationInputAt, inputAt); + compare(main.gamesNavigationModelReadyAt, 0); + compare(main.gamesNavigationAction, "back"); + compare(main.gamesScreen.lastNavigationInputAt, 0); + } + + function test_system_covers_wait_for_destination_frame(): void { + main.activeScreen = main.screenSystems; + main.systemsCoverRevealReady = false; + compare(main.systemsScreen.coverRevealReady, false); + compare(main.systemsScreen.systemsGrid.coverRequestsEnabled, false); + + main.systemsCoverRevealReady = true; + compare(main.systemsScreen.coverRevealReady, true); + } + + function test_system_grid_withholds_covers_during_transition(): void { + main.activeScreen = main.screenHub; + main.pendingTransition = "systems"; + compare(main.systemsScreen.systemsGrid.suspendDelegates, false); + compare(main.systemsScreen.systemsGrid.coverRequestsEnabled, false); + compare(main.systemsScreen.systemsGrid.coverLookaheadPages, 0); + compare(main.systemsScreen.systemsGrid.eagerFocusedCovers, false); + + main.pendingTransition = ""; + main.activeScreen = main.screenSystems; + compare(main.systemsScreen.preparingTransition, false); + } + + function test_transition_timing_closes_on_presented_frame(): void { + main._beginTransitionTiming("accept"); + main._markTransitionRouted(main.screenSystems); + compare(main._transitionAction, "accept"); + compare(main._transitionFromScreen, main.screenHub); + compare(main._transitionToScreen, main.screenSystems); + verify(main._transitionRouteAt > 0); + + main._finishTransitionTiming(); + compare(main._transitionInputStartedAt, 0); + compare(main._transitionRouteAt, 0); + compare(main._transitionToScreen, ""); + } + // Enter on an optimistic placeholder category starts the normal // systems loading transition and preserves the visible category // name instead of treating the row as empty. @@ -157,6 +284,24 @@ TestCase { compare(main.activeScreen, main.updateEnabled ? main.screenUpdate : main.screenSettings); } + function test_favorite_systems_grid_matches_system_tile_layout(): void { + compare(main.favoriteSystemsScreen.gridShowCaption, false); + compare(main.favoriteSystemsScreen.gridColumnsOverride, main.systemsScreen.systemsGrid.columns); + compare(main.favoriteSystemsScreen.gridRowsOverride, main.systemsScreen.systemsGrid.rows); + } + + function test_flat_favorites_uses_unbounded_page_chrome(): void { + compare(main.favoritesScreen.paginationTotalKnown, false); + compare(main.favoritesScreen.favoritesGrid.paginationTotalKnown, false); + verify(main.favoritesScreen.topStrip.pageText.indexOf("/") < 0); + } + + function test_recents_uses_unbounded_page_chrome(): void { + compare(main.recentsScreen.paginationTotalKnown, false); + compare(main.recentsScreen.recentsGrid.paginationTotalKnown, false); + verify(main.recentsScreen.topStrip.pageText.indexOf("/") < 0); + } + function test_hub_favorites_action_uses_favorite_systems_mode(): void { Browse.Settings.set_favorites_grouping("system"); main.hubScreen.currentRow = 1; @@ -527,17 +672,28 @@ TestCase { compare(main._repeatPending, true, "Re-arm restarts the initial-delay timer"); } - function test_rapid_navigation_taps_activate_on_second_press(): void { - main._noteRapidNavigationAction("down", false); - compare(main.rapidNavigationAction, "down", "rapid action tracks latest rapid input even before active mode"); - compare(main.rapidNavigationActive, false, "single isolated press should not enter rapid mode"); + function test_rapid_navigation_taps_require_sustained_same_direction(): void { + for (let i = 1; i < main._rapidNavigationTapThreshold; ++i) { + main._noteRapidNavigationAction("down", false); + compare(main.rapidNavigationActive, false, "ordinary repeated taps stay out of rapid mode"); + } main._noteRapidNavigationAction("down", false); - compare(main.rapidNavigationActive, true, "second press inside quiet window enters rapid mode"); + compare(main.rapidNavigationActive, true, "fourth same-direction tap inside quiet window enters rapid mode"); + compare(main.rapidNavigationIndicatorActive, true); wait(main._rapidNavigationQuietMs + 40); compare(main.rapidNavigationActive, false, "rapid mode clears after quiet window"); compare(main.rapidNavigationAction, "", "quiet reset clears rapid action"); } + function test_rapid_navigation_alternating_taps_never_activate(): void { + const actions = ["up", "down", "up", "down", "up"]; + for (let i = 0; i < actions.length; ++i) { + main._noteRapidNavigationAction(actions[i], false); + compare(main.rapidNavigationActive, false, "direction changes must reset rapid-mode tap evidence"); + compare(main.rapidNavigationIndicatorActive, false); + } + } + function test_rapid_navigation_ignores_non_rapid_action(): void { main._noteRapidNavigationAction("accept", true); compare(main.rapidNavigationActive, false); diff --git a/tests/ui/tst_paged_grid.qml b/tests/ui/tst_paged_grid.qml index a8586c23..839177ae 100644 --- a/tests/ui/tst_paged_grid.qml +++ b/tests/ui/tst_paged_grid.qml @@ -141,6 +141,7 @@ TestCase { // (which skips its cleanup) doesn't poison the next case's // pageCount/totalPageCount math. grid.hasMorePages = false; + grid.paginationTotalKnown = true; grid.totalItemsOverride = -1; fillModel(0); grid.setCurrentIndexImmediate(0); @@ -185,6 +186,7 @@ TestCase { compare(grid.columns, 4, "expected 4 columns at 480px height"); compare(grid.rows, 3, "expected 3 rows at 480px height"); compare(grid.pageSize, 12); + compare(grid._coverRetentionPages, 2, "tile retention must convert cover count to pages"); } function test_empty_model_refuses_movement(): void { @@ -194,6 +196,20 @@ TestCase { compare(grid.currentIndex, 0); } + function test_prepare_for_model_replacement_clears_pending_target(): void { + fillModel(20); + grid.totalItemsOverride = 100; + grid.hasMorePages = true; + grid.setCurrentIndexImmediate(13); + compare(grid.jumpToIndex(50), false); + compare(grid.hasPendingTarget, true); + + grid.prepareForModelReplacement(); + + compare(grid.hasPendingTarget, false); + compare(grid.currentIndex, 0); + } + function test_within_page_step_right(): void { fillModel(20); compare(grid.currentIndex, 0); @@ -446,6 +462,49 @@ TestCase { compare(grid.hasPagesBelow, false); } + function test_single_page_returns_unused_scroll_gutter_to_cells(): void { + fillModel(6); + compare(grid._scrollIndicatorVisible, false); + const singlePageCellWidth = grid.cellWidth; + grid.totalItemsOverride = 60; + compare(grid._scrollIndicatorVisible, true); + verify(grid.cellWidth < singlePageCellWidth); + grid.totalItemsOverride = -1; + } + + function test_unbounded_pages_keep_down_arrow_at_loaded_edge(): void { + fillModel(24); + grid.paginationTotalKnown = false; + grid.hasMorePages = true; + grid.setCurrentIndexImmediate(12); + compare(grid.currentPage, grid.pageCount - 1); + compare(grid.hasPagesAbove, true); + compare(grid.hasPagesBelow, true); + } + + function test_unbounded_page_next_fetches_instead_of_wrapping(): void { + fillModel(24); + grid.paginationTotalKnown = false; + grid.hasMorePages = true; + grid.setCurrentIndexImmediate(12); + loadMoreSpy.clear(); + + compare(grid.pageBy(1), false); + compare(grid.currentIndex, 12); + compare(grid._pendingTargetPage, 2); + verify(loadMoreSpy.count >= 1); + } + + function test_unbounded_page_zero_does_not_wrap_backward(): void { + fillModel(24); + grid.paginationTotalKnown = false; + grid.hasMorePages = true; + compare(grid.pageBy(-1), false); + compare(grid.moveSelection(0, -1), false); + compare(grid.currentIndex, 0); + compare(grid.hasPendingTarget, false); + } + // ── Scroll thumb sizing (totalItemsOverride) ───────────────────────── function test_totalPageCount_uses_override(): void { diff --git a/tests/ui/tst_resources.qml b/tests/ui/tst_resources.qml index c427ef5e..1f2c25f7 100644 --- a/tests/ui/tst_resources.qml +++ b/tests/ui/tst_resources.qml @@ -5,14 +5,63 @@ import QtQuick import QtTest import Zaparoo.Theme +import Zaparoo.Ui // Resources.coverUrl is the single source of truth for turning a model // cover key into an image:// URL. These tests lock the routing contract, // especially that user `custom-image/` overrides bypass the tint pipeline // and are served exactly as-is. TestCase { + id: testCase + name: "UiResources" + Component { + id: headerBarComponent + + HeaderBar { + width: 960 + } + } + + Component { + id: statusPillComponent + + CoreStatusPill {} + } + + Component { + id: scrollingCaptionComponent + + ScrollingCaption { + width: 120 + height: 24 + name: "jjjj WAVE" + tags: "Rev A" + } + } + + Component { + id: missingSystemTile + + Item { + width: 240 + height: 160 + property bool isSelected: false + property bool isFocused: false + property string name: "Apogee" + property string coverKey: "systems/Apogee" + property string topLabel: "" + property int favorite: 0 + property bool hidden: false + property string disambiguatingTags: "" + + Tile { + anchors.fill: parent + } + } + } + function test_custom_image_key_routes_to_custom_provider(): void { const url = String(Resources.coverUrl("custom-image//media/fat/zaparoo/custom/systems/SNES.png", "#111111", "#222222", "#333333")); compare(url, "image://custom-image//media/fat/zaparoo/custom/systems/SNES.png"); @@ -52,6 +101,109 @@ TestCase { verify(sys.startsWith("image://tinted-svg/")); } + function test_missing_system_logo_attempts_load_then_shows_text_on_error(): void { + Resources.systemLogoStyle = "tinted"; + const url = String(Resources.coverUrl("systems/Apogee", "#ffffff", "#888888", "#000000")); + verify(url.startsWith("image://tinted-svg/"), "missing system artwork must still be attempted"); + + const host = createTemporaryObject(missingSystemTile, testCase); + verify(host !== null); + const fallback = findChild(host, "tileFallbackText"); + verify(fallback !== null); + compare(fallback.text, "Apogee"); + tryCompare(fallback, "opacity", 1.0, 500); + } + + function test_non_system_image_error_never_shows_text_fallback(): void { + const host = createTemporaryObject(missingSystemTile, testCase, { + "coverKey": "categories/__missing_category__", + "name": "Missing category" + }); + verify(host !== null); + const fallback = findChild(host, "tileFallbackText"); + verify(fallback !== null); + wait(20); + compare(fallback.opacity, 0.0); + } + + function test_header_hides_obviously_invalid_clock_dates(): void { + const header = createTemporaryObject(headerBarComponent, testCase); + verify(header !== null); + compare(header._clockDateValid(new Date(1970, 0, 1)), false); + compare(header._clockDateValid(new Date(2019, 11, 31)), false); + compare(header._clockDateValid(new Date(2020, 0, 1)), true); + } + + function test_status_pill_uses_available_header_width_cap(): void { + const pill = createTemporaryObject(statusPillComponent, testCase, { + "maximumWidth": 180 + }); + verify(pill !== null); + compare(pill._boundedWidth(250), 180); + pill.maximumWidth = 500; + compare(pill._boundedWidth(250), 250); + pill.maximumWidth = 0; + compare(pill._boundedWidth(250), 250); + } + + function test_scrolling_caption_measures_painted_glyph_bounds(): void { + const caption = createTemporaryObject(scrollingCaptionComponent, testCase); + verify(caption !== null); + const nameMetrics = findChild(caption, "scrollingCaptionNameMetrics"); + const tagsMetrics = findChild(caption, "scrollingCaptionTagsMetrics"); + verify(nameMetrics !== null); + verify(tagsMetrics !== null); + const expectedNameWidth = Math.ceil(Math.max(nameMetrics.advanceWidth, nameMetrics.boundingRect.x + nameMetrics.boundingRect.width) - Math.min(0, nameMetrics.boundingRect.x)); + const expectedTagsWidth = Math.ceil(Math.max(tagsMetrics.advanceWidth, tagsMetrics.boundingRect.x + tagsMetrics.boundingRect.width) - Math.min(0, tagsMetrics.boundingRect.x)); + compare(caption._nameFullW, expectedNameWidth); + compare(caption._tagsFullW, expectedTagsWidth); + } + + function test_media_cover_uses_short_reveal_without_loading_glyph(): void { + const host = createTemporaryObject(missingSystemTile, testCase, { + "coverKey": "media-image/example" + }); + verify(host !== null); + const reveal = findChild(host, "tileCoverRevealAnimation"); + verify(reveal !== null); + compare(reveal.duration, Motion.dur(Motion.pressMs)); + compare(findChild(host, "tileLoadingGlyph"), null); + } + + function test_tile_top_label_reserves_space_above_cover(): void { + const host = createTemporaryObject(missingSystemTile, testCase, { + "coverKey": "media-image/example", + "topLabel": "Super Nintendo" + }); + verify(host !== null); + const label = findChild(host, "tileTopLabel"); + const cover = findChild(host, "tileCoverBase"); + verify(label !== null); + verify(cover !== null); + compare(label.text, "Super Nintendo"); + verify(cover.y >= label.y + label.height, "cover must start below system label"); + } + + function test_tile_without_top_label_keeps_cover_at_default_inset(): void { + const plainHost = createTemporaryObject(missingSystemTile, testCase); + const labelledHost = createTemporaryObject(missingSystemTile, testCase, { + "topLabel": "Super Nintendo" + }); + verify(plainHost !== null); + verify(labelledHost !== null); + const plainLabel = findChild(plainHost, "tileTopLabel"); + const plainCover = findChild(plainHost, "tileCoverBase"); + const labelledCover = findChild(labelledHost, "tileCoverBase"); + compare(plainLabel.text, ""); + verify(plainCover.y < labelledCover.y, "empty labels must not reserve top space"); + } + + function test_system_artwork_alias_is_checked_after_remap(): void { + Resources.systemLogoStyle = "tinted"; + const sys = String(Resources.coverUrl("systems/MacPlus", "#ffffff", "#888888", "#000000")); + verify(sys.endsWith("/images/systems/MacOS.svg")); + } + function test_empty_key_returns_empty(): void { compare(String(Resources.coverUrl("", "#ffffff", "#888888", "#000000")), ""); } diff --git a/tests/ui/tst_sizing.qml b/tests/ui/tst_sizing.qml index 1e60ae39..32124548 100644 --- a/tests/ui/tst_sizing.qml +++ b/tests/ui/tst_sizing.qml @@ -140,6 +140,12 @@ TestCase { setResolution(1280, 720); } + function test_half_size_1080p_games_grid_keeps_normal_page_density(): void { + const shape = Sizing.gamesGridShape(960, 365); + compare(shape.columns, 5); + compare(shape.rows, 2); + } + function test_crt_systems_grid_is_three_by_three(): void { Sizing.crtNativePath = true; setResolution(352, 240); From 00007db1e76a9f8af31f7ac2e50dd5aa5d6c9a2f Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 17 Aug 2026 06:09:11 +0800 Subject: [PATCH 5/9] fix: address browsing review feedback Invalidate all Games roles when first-page rows are replaced, keep centered native text on integer geometry, and scale grid thresholds with the logical viewport. Deduplicate inline image fallback, derive batch limits from the shared constant, and harden delayed fallback coverage. --- rust/frontend/src/media_image_cache.rs | 23 +++++++++++++++-------- rust/frontend/src/models/games.rs | 13 ++++--------- rust/zaparoo-core/src/client.rs | 5 ++--- rust/zaparoo-core/src/media_types.rs | 20 +++++++++++++------- src/ui/components/QrCodeModal.qml | 15 +++++++++++++-- src/ui/components/Tile.qml | 20 ++++++++++++++------ src/ui/theme/Sizing.qml | 6 +++--- src/ui/translations/frontend_ar.ts | 4 ++-- src/ui/translations/frontend_de.ts | 4 ++-- src/ui/translations/frontend_el.ts | 4 ++-- src/ui/translations/frontend_en.ts | 4 ++-- src/ui/translations/frontend_es.ts | 4 ++-- src/ui/translations/frontend_eu.ts | 4 ++-- src/ui/translations/frontend_fr.ts | 4 ++-- src/ui/translations/frontend_he.ts | 4 ++-- src/ui/translations/frontend_hi.ts | 4 ++-- src/ui/translations/frontend_it.ts | 4 ++-- src/ui/translations/frontend_ja.ts | 4 ++-- src/ui/translations/frontend_ko.ts | 4 ++-- src/ui/translations/frontend_nl.ts | 4 ++-- src/ui/translations/frontend_ro.ts | 4 ++-- src/ui/translations/frontend_sk.ts | 4 ++-- src/ui/translations/frontend_uk.ts | 4 ++-- src/ui/translations/frontend_zh_CN.ts | 4 ++-- tests/ui/tst_resources.qml | 2 +- 25 files changed, 99 insertions(+), 73 deletions(-) diff --git a/rust/frontend/src/media_image_cache.rs b/rust/frontend/src/media_image_cache.rs index 892a5498..0512c0aa 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -1360,6 +1360,16 @@ async fn read_local_image(path: String) -> Result, String> { } } +async fn fetch_inline_media_image( + store: &Arc, + mut params: MediaImageParams, +) -> (Result, Duration) { + params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); + let started = Instant::now(); + let result = store.client().media_image(params).await; + (result, started.elapsed()) +} + async fn fetch_media_image_payload( store: &Arc, key: &MediaKey, @@ -1384,10 +1394,9 @@ async fn fetch_media_image_payload( path = %key.path, "media_image_cache: Core rejected local-path delivery; using inline for this session" ); - params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); - let fallback_started = Instant::now(); - let fallback = store.client().media_image(params.clone()).await; - rpc_duration += fallback_started.elapsed(); + let (fallback, fallback_duration) = + fetch_inline_media_image(store, params.clone()).await; + rpc_duration += fallback_duration; fallback? } Err(error) => return Err(error), @@ -1431,10 +1440,8 @@ async fn fetch_media_image_payload( ); } - params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); - let fallback_started = Instant::now(); - let fallback = store.client().media_image(params).await; - rpc_duration += fallback_started.elapsed(); + let (fallback, fallback_duration) = fetch_inline_media_image(store, params).await; + rpc_duration += fallback_duration; Ok(FetchedMediaImage { image: fallback?, local_bytes: None, diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 29b2ba2e..94420dcf 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -3159,15 +3159,10 @@ fn replace_initial_rows( let parent = QModelIndex::default(); let top_left = model.as_mut().index(0, 0, &parent); let bottom_right = model.as_mut().index(count - 1, 0, &parent); - // Repeater delegates consume only these roles. An empty roles list means - // "all roles" and needlessly re-evaluates path, launch, description, - // file-count, and entry-type consumers during the first-frame update. - let mut roles = QList::::default(); - roles.append(NAME_ROLE); - roles.append(COVER_KEY_ROLE); - roles.append(FAVORITE_ROLE); - roles.append(HIDDEN_ROLE); - roles.append(DISAMBIGUATING_TAGS_ROLE); + // Prefix delegates survive both in-place paths, but rows may belong to a + // different folder. Empty roles invalidates every exposed value so path, + // launch, identity, and metadata cannot remain bound to the old row. + let roles = QList::::default(); model .as_mut() .data_changed(&top_left, &bottom_right, &roles); diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index 287c1e3f..b7eed4e8 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -679,9 +679,8 @@ impl Client { &self, items: Vec, ) -> Result { - let params = MediaMetaBatchParams::try_new(items).map_err(|message| ClientError { - message: message.to_string(), - })?; + let params = + MediaMetaBatchParams::try_new(items).map_err(|message| ClientError { message })?; let val = self.call("media.meta", ¶ms).await?; deserialize_timed("media.meta batch", val) } diff --git a/rust/zaparoo-core/src/media_types.rs b/rust/zaparoo-core/src/media_types.rs index 492777f8..25234256 100644 --- a/rust/zaparoo-core/src/media_types.rs +++ b/rust/zaparoo-core/src/media_types.rs @@ -665,12 +665,14 @@ pub struct MediaMetaBatchParams { } impl MediaMetaBatchParams { - pub fn try_new(items: Vec) -> Result { + pub fn try_new(items: Vec) -> Result { if items.is_empty() { - return Err("media.meta batch must contain at least one item"); + return Err("media.meta batch must contain at least one item".to_string()); } if items.len() > MEDIA_META_BATCH_MAX_ITEMS { - return Err("media.meta batch cannot contain more than 100 items"); + return Err(format!( + "media.meta batch cannot contain more than {MEDIA_META_BATCH_MAX_ITEMS} items" + )); } Ok(Self { items }) } @@ -1294,6 +1296,7 @@ mod tests { ReaderInfo, ReadersResult, ScrapersResult, ScrapingStatusResponse, SettingsResult, SystemDefault, SystemsParams, SystemsResult, TagInfo, TokensHistoryResult, TokensResult, UpdateSettingsParams, VersionResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, + MEDIA_META_BATCH_MAX_ITEMS, }; #[test] @@ -2026,10 +2029,13 @@ mod tests { #[test] fn media_meta_batch_enforces_item_cap() { - let hundred = vec![MediaMetaParams::for_media_id(1); 100]; - assert!(MediaMetaBatchParams::try_new(hundred).is_ok()); - let hundred_one = vec![MediaMetaParams::for_media_id(1); 101]; - assert!(MediaMetaBatchParams::try_new(hundred_one).is_err()); + let max_items = vec![MediaMetaParams::for_media_id(1); MEDIA_META_BATCH_MAX_ITEMS]; + assert!(MediaMetaBatchParams::try_new(max_items).is_ok()); + let over_limit = vec![MediaMetaParams::for_media_id(1); MEDIA_META_BATCH_MAX_ITEMS + 1]; + assert_eq!( + MediaMetaBatchParams::try_new(over_limit).expect_err("over limit"), + format!("media.meta batch cannot contain more than {MEDIA_META_BATCH_MAX_ITEMS} items") + ); assert!(MediaMetaBatchParams::try_new(Vec::new()).is_err()); } diff --git a/src/ui/components/QrCodeModal.qml b/src/ui/components/QrCodeModal.qml index 92e4d55e..f8a0623a 100644 --- a/src/ui/components/QrCodeModal.qml +++ b/src/ui/components/QrCodeModal.qml @@ -39,13 +39,24 @@ Item { width: parent.width spacing: Sizing.pctH(2) + TextMetrics { + id: instructionsMetrics + + font.family: Theme.fontUi + font.pixelSize: Sizing.fontSize(2.4) + text: instructions.text + } + Text { - width: parent.width + id: instructions + + x: Sizing.center(parent.width, width) + width: Math.min(parent.width, Sizing.px(instructionsMetrics.advanceWidth)) text: qsTr("Scan this code with your phone to write this game to a Zaparoo token.") font.family: Theme.fontUi font.pixelSize: Sizing.fontSize(2.4) color: Theme.textPrimary - horizontalAlignment: Text.AlignHCenter + horizontalAlignment: Text.AlignLeft wrapMode: Text.WordWrap renderType: Text.NativeRendering } diff --git a/src/ui/components/Tile.qml b/src/ui/components/Tile.qml index 6009980e..c00a24f7 100644 --- a/src/ui/components/Tile.qml +++ b/src/ui/components/Tile.qml @@ -391,17 +391,25 @@ Item { // // `_focusCoverActive` suppresses coverBase when the focused ramp is on top, // preventing the two opaque layers from stacking their alpha on hidden tiles. + TextMetrics { + id: topLabelMetrics + + font.family: Theme.fontUi + font.pixelSize: root._topLabelTextSize + font.weight: Font.Medium + text: root.delegateTopLabel + } + Text { objectName: "tileTopLabel" - x: root._captionSideInset - y: root._padding - width: root._captionTextMaxWidth - height: root._topLabelHeight + x: Sizing.center(parent.width, width) + y: root._padding + Sizing.center(root._topLabelHeight, height) + width: Math.min(root._captionTextMaxWidth, Sizing.px(topLabelMetrics.advanceWidth)) + height: Sizing.px(implicitHeight) visible: root._hasTopLabel text: root.delegateTopLabel elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignLeft font.family: Theme.fontUi font.pixelSize: root._topLabelTextSize font.weight: Font.Medium diff --git a/src/ui/theme/Sizing.qml b/src/ui/theme/Sizing.qml index d5f4ae21..8bc3637f 100644 --- a/src/ui/theme/Sizing.qml +++ b/src/ui/theme/Sizing.qml @@ -42,10 +42,10 @@ QtObject { // rotating the scene changes how many tiles fit without stretching // the cards into a different shape. readonly property var _gamesGridConfig: _gridConfig(_browseGridBaseConfig, { - // A 1080p MiSTer output renders through a 960x540 framebuffer. Its - // content viewport is about 365px tall, so 170px preserves the normal + // A 1080p MiSTer output renders through a 960x540 framebuffer. At that + // logical height, 31.5% resolves to 170px and preserves the normal // five-column, two-row page instead of falling back to 2x2. - "minCellHeight": crtNativePath ? 96 : 170, + "minCellHeight": crtNativePath ? 96 : pctH(31.5), "targetAspect": crtNativePath ? 0.78 : 0.71 }) readonly property var _gamesGridShape: gamesGridShape(screenWidth, screenHeight) diff --git a/src/ui/translations/frontend_ar.ts b/src/ui/translations/frontend_ar.ts index 212d7050..3f4735f9 100644 --- a/src/ui/translations/frontend_ar.ts +++ b/src/ui/translations/frontend_ar.ts @@ -1139,7 +1139,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1753,7 +1753,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_de.ts b/src/ui/translations/frontend_de.ts index 28f50c23..063d5109 100644 --- a/src/ui/translations/frontend_de.ts +++ b/src/ui/translations/frontend_de.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_el.ts b/src/ui/translations/frontend_el.ts index d0587a35..0ffd637b 100644 --- a/src/ui/translations/frontend_el.ts +++ b/src/ui/translations/frontend_el.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_en.ts b/src/ui/translations/frontend_en.ts index ca911205..7f1a5729 100644 --- a/src/ui/translations/frontend_en.ts +++ b/src/ui/translations/frontend_en.ts @@ -1071,7 +1071,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1673,7 +1673,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_es.ts b/src/ui/translations/frontend_es.ts index 2ca8e4d9..f4654f03 100644 --- a/src/ui/translations/frontend_es.ts +++ b/src/ui/translations/frontend_es.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_eu.ts b/src/ui/translations/frontend_eu.ts index 06b69b8c..53068d23 100644 --- a/src/ui/translations/frontend_eu.ts +++ b/src/ui/translations/frontend_eu.ts @@ -1139,7 +1139,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1753,7 +1753,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_fr.ts b/src/ui/translations/frontend_fr.ts index c95131b5..dbf9d6d0 100644 --- a/src/ui/translations/frontend_fr.ts +++ b/src/ui/translations/frontend_fr.ts @@ -1114,7 +1114,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1720,7 +1720,7 @@ Français - Wilfried Tile - + Hidden Masqué diff --git a/src/ui/translations/frontend_he.ts b/src/ui/translations/frontend_he.ts index 402dcc71..7716a58c 100644 --- a/src/ui/translations/frontend_he.ts +++ b/src/ui/translations/frontend_he.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_hi.ts b/src/ui/translations/frontend_hi.ts index e9abb66c..4ec46c50 100644 --- a/src/ui/translations/frontend_hi.ts +++ b/src/ui/translations/frontend_hi.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_it.ts b/src/ui/translations/frontend_it.ts index fa7ff763..0c0ece02 100644 --- a/src/ui/translations/frontend_it.ts +++ b/src/ui/translations/frontend_it.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ja.ts b/src/ui/translations/frontend_ja.ts index a23f12b3..a5468a0a 100644 --- a/src/ui/translations/frontend_ja.ts +++ b/src/ui/translations/frontend_ja.ts @@ -1124,7 +1124,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ko.ts b/src/ui/translations/frontend_ko.ts index 48e14f74..5a762506 100644 --- a/src/ui/translations/frontend_ko.ts +++ b/src/ui/translations/frontend_ko.ts @@ -1124,7 +1124,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_nl.ts b/src/ui/translations/frontend_nl.ts index 97a0677e..ac761df9 100644 --- a/src/ui/translations/frontend_nl.ts +++ b/src/ui/translations/frontend_nl.ts @@ -1127,7 +1127,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ro.ts b/src/ui/translations/frontend_ro.ts index 5bab3a96..db8763f9 100644 --- a/src/ui/translations/frontend_ro.ts +++ b/src/ui/translations/frontend_ro.ts @@ -1130,7 +1130,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_sk.ts b/src/ui/translations/frontend_sk.ts index beb78093..f87b349f 100644 --- a/src/ui/translations/frontend_sk.ts +++ b/src/ui/translations/frontend_sk.ts @@ -1130,7 +1130,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_uk.ts b/src/ui/translations/frontend_uk.ts index fb94a659..f740b1d1 100644 --- a/src/ui/translations/frontend_uk.ts +++ b/src/ui/translations/frontend_uk.ts @@ -1130,7 +1130,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_zh_CN.ts b/src/ui/translations/frontend_zh_CN.ts index a9e45e6d..d61607a1 100644 --- a/src/ui/translations/frontend_zh_CN.ts +++ b/src/ui/translations/frontend_zh_CN.ts @@ -1124,7 +1124,7 @@ Français - Wilfried - + Scan this code with your phone to write this game to a Zaparoo token. @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/tests/ui/tst_resources.qml b/tests/ui/tst_resources.qml index 1f2c25f7..d8bb5cb0 100644 --- a/tests/ui/tst_resources.qml +++ b/tests/ui/tst_resources.qml @@ -122,7 +122,7 @@ TestCase { verify(host !== null); const fallback = findChild(host, "tileFallbackText"); verify(fallback !== null); - wait(20); + wait(500); compare(fallback.opacity, 0.0); } From 88d06045391b5dc760f42e4aecd41fa15dad2b3e Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 17 Aug 2026 06:22:44 +0800 Subject: [PATCH 6/9] test(catalog): update shaped fixture snapshot Record indexed media counts now populated by the catalog fixture helper so CI matches the intentional filtered catalog shape. --- ...hape_catalog_snapshot_matches_fixture.snap | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/rust/zaparoo-core/src/endpoints/snapshots/zaparoo_core__endpoints__catalog__tests__shape_catalog_snapshot_matches_fixture.snap b/rust/zaparoo-core/src/endpoints/snapshots/zaparoo_core__endpoints__catalog__tests__shape_catalog_snapshot_matches_fixture.snap index fd3b3a61..e16736bc 100644 --- a/rust/zaparoo-core/src/endpoints/snapshots/zaparoo_core__endpoints__catalog__tests__shape_catalog_snapshot_matches_fixture.snap +++ b/rust/zaparoo-core/src/endpoints/snapshots/zaparoo_core__endpoints__catalog__tests__shape_catalog_snapshot_matches_fixture.snap @@ -1,6 +1,6 @@ --- source: zaparoo-core/src/endpoints/catalog.rs -assertion_line: 140 +assertion_line: 194 expression: shape_catalog(systems) --- CatalogData { @@ -11,7 +11,9 @@ CatalogData { category: "Handhelds", release_date: None, manufacturer: None, - media_count: None, + media_count: Some( + 1, + ), zap_script: "", }, SystemInfo { @@ -20,7 +22,9 @@ CatalogData { category: "arcade", release_date: None, manufacturer: None, - media_count: None, + media_count: Some( + 1, + ), zap_script: "", }, SystemInfo { @@ -29,7 +33,9 @@ CatalogData { category: "Consoles", release_date: None, manufacturer: None, - media_count: None, + media_count: Some( + 1, + ), zap_script: "", }, SystemInfo { @@ -38,7 +44,9 @@ CatalogData { category: "", release_date: None, manufacturer: None, - media_count: None, + media_count: Some( + 1, + ), zap_script: "", }, SystemInfo { @@ -47,7 +55,9 @@ CatalogData { category: "Consoles", release_date: None, manufacturer: None, - media_count: None, + media_count: Some( + 1, + ), zap_script: "", }, ], From 41f40eae75d2954014591ea89519658fd47dc62b Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 18 Aug 2026 09:53:19 +0800 Subject: [PATCH 7/9] fix: harden browsing and MiSTer runtime Bound metadata and image work, preserve pagination ordering, validate framebuffer fallback, and improve output-aware UI and CLI behavior. Co-authored-by: Giancarlo Erra --- cmake/ZaparooRust.cmake | 2 + rust/frontend/src/media_image_cache.rs | 132 +++++++++-- rust/frontend/src/media_meta_cache.rs | 276 +++++++++++++++++++++- rust/frontend/src/mister_runtime.rs | 109 +++++++-- rust/frontend/src/models/favorites.rs | 18 +- rust/frontend/src/models/games.rs | 80 ++++--- rust/frontend/src/models/recents.rs | 56 +++-- rust/mock-core/src/fixtures.rs | 38 ++- rust/mock-core/src/handler.rs | 48 +++- rust/zaparoo-core/src/client.rs | 33 +++ src/app/frontend_arguments.cpp | 55 +++++ src/app/frontend_arguments.h | 22 ++ src/app/main.cpp | 85 +++---- src/ui/app/Main.qml | 18 +- src/ui/app/MainLayout.qml | 7 + src/ui/components/CoreStatusPill.qml | 20 +- src/ui/components/PagedGrid.qml | 20 +- src/ui/components/ScrollingCaption.qml | 5 + src/ui/components/Tile.qml | 9 +- src/ui/screens/GamesScreen.qml | 1 + src/ui/screens/MediaListScreen.qml | 2 + src/ui/theme/Theme.qml | 3 + src/ui/translations/frontend_ar.ts | 310 ++++++++++++------------- src/ui/translations/frontend_de.ts | 310 ++++++++++++------------- src/ui/translations/frontend_el.ts | 310 ++++++++++++------------- src/ui/translations/frontend_en.ts | 310 ++++++++++++------------- src/ui/translations/frontend_es.ts | 310 ++++++++++++------------- src/ui/translations/frontend_eu.ts | 310 ++++++++++++------------- src/ui/translations/frontend_fr.ts | 310 ++++++++++++------------- src/ui/translations/frontend_he.ts | 310 ++++++++++++------------- src/ui/translations/frontend_hi.ts | 310 ++++++++++++------------- src/ui/translations/frontend_it.ts | 310 ++++++++++++------------- src/ui/translations/frontend_ja.ts | 310 ++++++++++++------------- src/ui/translations/frontend_ko.ts | 310 ++++++++++++------------- src/ui/translations/frontend_nl.ts | 310 ++++++++++++------------- src/ui/translations/frontend_ro.ts | 310 ++++++++++++------------- src/ui/translations/frontend_sk.ts | 310 ++++++++++++------------- src/ui/translations/frontend_uk.ts | 310 ++++++++++++------------- src/ui/translations/frontend_zh_CN.ts | 310 ++++++++++++------------- tests/CMakeLists.txt | 30 +++ tests/check_frontend_version.cmake | 25 ++ tests/tst_frontend_arguments.cpp | 82 +++++++ tests/ui/tst_navigation.qml | 16 ++ tests/ui/tst_paged_grid.qml | 23 ++ tests/ui/tst_resources.qml | 128 ++++++++++ 45 files changed, 3780 insertions(+), 2833 deletions(-) create mode 100644 src/app/frontend_arguments.cpp create mode 100644 src/app/frontend_arguments.h create mode 100644 tests/check_frontend_version.cmake create mode 100644 tests/tst_frontend_arguments.cpp diff --git a/cmake/ZaparooRust.cmake b/cmake/ZaparooRust.cmake index 2f0c2015..d1c19d25 100644 --- a/cmake/ZaparooRust.cmake +++ b/cmake/ZaparooRust.cmake @@ -83,6 +83,8 @@ endif() qt_add_executable( frontend "${CMAKE_SOURCE_DIR}/src/app/main.cpp" + "${CMAKE_SOURCE_DIR}/src/app/frontend_arguments.h" + "${CMAKE_SOURCE_DIR}/src/app/frontend_arguments.cpp" "${CMAKE_SOURCE_DIR}/src/app/media_image_provider.h" "${CMAKE_SOURCE_DIR}/src/app/media_image_provider.cpp" "${CMAKE_SOURCE_DIR}/src/app/tinted_svg_image_provider.h" diff --git a/rust/frontend/src/media_image_cache.rs b/rust/frontend/src/media_image_cache.rs index 0512c0aa..1ecb9398 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -46,8 +46,7 @@ use tracing::{debug, info, warn}; use zaparoo_core::client::ClientError; use zaparoo_core::media_types::{ - MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_INLINE, - MEDIA_IMAGE_DELIVERY_LOCAL_PATH, + MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, }; use zaparoo_core::runtime; use zaparoo_core::store::Store; @@ -73,6 +72,10 @@ const NEGATIVE_MEMO_CAP: usize = 4096; /// tiles rather than the ~110 full-resolution SNES covers that fit at /// 64 MiB. const CACHE_CAP_BYTES: usize = 128 * 1024 * 1024; +/// Core's `localPath` response is a resized thumbnail, never an arbitrary +/// source image. Bound one file well below total cache capacity so a corrupt, +/// replaced, or remote-host path cannot allocate `MiSTer`'s remaining RAM. +const MAX_LOCAL_IMAGE_BYTES: usize = 16 * 1024 * 1024; /// Maximum retries for a single key after a transient fetch failure /// (RPC error, base64 decode error). Generous enough to ride through @@ -1338,9 +1341,21 @@ struct FetchedMediaImage { } fn should_request_local_path(max_size: u32) -> bool { - max_size > 0 - && runtime::current().is_mister() - && !LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Relaxed) + local_path_request_allowed( + max_size, + runtime::current().is_mister(), + crate::models::core_is_local(), + LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Relaxed), + ) +} + +fn local_path_request_allowed( + max_size: u32, + frontend_is_mister: bool, + core_is_local: bool, + disabled: bool, +) -> bool { + max_size > 0 && frontend_is_mister && core_is_local && !disabled } fn is_unsupported_local_path_error(message: &str) -> bool { @@ -1352,21 +1367,64 @@ fn is_unsupported_local_path_error(message: &str) -> bool { } async fn read_local_image(path: String) -> Result, String> { - match tokio::task::spawn_blocking(move || std::fs::read(path)).await { - Ok(Ok(bytes)) if !bytes.is_empty() => Ok(bytes), - Ok(Ok(_)) => Err("thumbnail file was empty".to_string()), - Ok(Err(error)) => Err(error.to_string()), + match tokio::task::spawn_blocking(move || read_local_image_file(&path, MAX_LOCAL_IMAGE_BYTES)) + .await + { + Ok(result) => result, Err(error) => Err(format!("blocking thumbnail read failed: {error}")), } } +fn read_local_image_file(path: &str, max_bytes: usize) -> Result, String> { + use std::io::Read as _; + + let file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let metadata = file.metadata().map_err(|error| error.to_string())?; + if !metadata.is_file() { + return Err("thumbnail path is not a regular file".to_string()); + } + let length = usize::try_from(metadata.len()) + .map_err(|_| "thumbnail file size does not fit memory limits".to_string())?; + if length == 0 { + return Err("thumbnail file was empty".to_string()); + } + if length > max_bytes { + return Err(format!( + "thumbnail file exceeds {max_bytes}-byte read limit" + )); + } + + let mut bytes = Vec::with_capacity(length); + file.take(max_bytes.saturating_add(1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.is_empty() { + return Err("thumbnail file was empty".to_string()); + } + if bytes.len() > max_bytes { + return Err(format!( + "thumbnail file exceeds {max_bytes}-byte read limit" + )); + } + Ok(bytes) +} + +fn inline_fallback_params(mut params: MediaImageParams) -> MediaImageParams { + // Omit the additive field entirely. A legacy Core that rejected + // `delivery: "localPath"` rejects `delivery: "inline"` too. + params.delivery = None; + params +} + async fn fetch_inline_media_image( store: &Arc, - mut params: MediaImageParams, + params: MediaImageParams, ) -> (Result, Duration) { - params.delivery = Some(MEDIA_IMAGE_DELIVERY_INLINE.to_string()); let started = Instant::now(); - let result = store.client().media_image(params).await; + let result = store + .client() + .media_image(inline_fallback_params(params)) + .await; (result, started.elapsed()) } @@ -1862,10 +1920,11 @@ mod tests { use super::{ classify_media_image_bytes, classify_single_media_image_error, ext_for_content_type, - ext_from_extension_field, finish_fetch, is_connection_down_error, - is_unsupported_local_path_error, pop_one, process_batch_outcomes, read_local_image, - CacheState, FetchOutcome, MediaImageCache, MediaImageUpdate, MediaKey, NegativeMemo, - NoImagePolicy, QueueEntry, MAX_QUEUE_LEN, NEGATIVE_MEMO_CAP, + ext_from_extension_field, finish_fetch, inline_fallback_params, is_connection_down_error, + is_unsupported_local_path_error, local_path_request_allowed, pop_one, + process_batch_outcomes, read_local_image, read_local_image_file, CacheState, FetchOutcome, + MediaImageCache, MediaImageUpdate, MediaKey, NegativeMemo, NoImagePolicy, QueueEntry, + MAX_QUEUE_LEN, NEGATIVE_MEMO_CAP, }; use std::collections::VecDeque; use std::io::Write as _; @@ -1874,7 +1933,9 @@ mod tests { use std::time::Instant; use tokio::runtime::Builder; use tokio::sync::{broadcast, Notify}; - use zaparoo_core::media_types::{MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH}; + use zaparoo_core::media_types::{ + MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, + }; /// Build a `MediaImageCache` without spawning the fetch driver. /// Lets tests exercise `enqueue` / `is_cached` / `is_negative` @@ -1895,6 +1956,15 @@ mod tests { } } + #[test] + fn local_path_delivery_requires_colocated_mister_core() { + assert!(local_path_request_allowed(256, true, true, false)); + assert!(!local_path_request_allowed(0, true, true, false)); + assert!(!local_path_request_allowed(256, false, true, false)); + assert!(!local_path_request_allowed(256, true, false, false)); + assert!(!local_path_request_allowed(256, true, true, true)); + } + #[test] fn clear_pending_requests_drops_queue_and_releases_pending_keys() { let cache = cache_for_test(); @@ -2149,6 +2219,15 @@ mod tests { assert!(!is_unsupported_local_path_error("stale media id")); } + #[test] + fn legacy_inline_fallback_omits_rejected_delivery_field() { + let params = inline_fallback_params(MediaImageParams { + delivery: Some(MEDIA_IMAGE_DELIVERY_LOCAL_PATH.to_string()), + ..MediaImageParams::default() + }); + assert!(params.delivery.is_none()); + } + #[test] fn local_path_bytes_use_existing_image_validation() { let key = MediaKey::new("SNES", "/p"); @@ -2171,7 +2250,7 @@ mod tests { } #[test] - fn local_path_read_accepts_bytes_and_rejects_missing_files() { + fn local_path_read_accepts_bounded_regular_files() { let mut file = tempfile::NamedTempFile::new().expect("temp file"); file.write_all(&[1, 2, 3]).expect("write temp image"); let existing = file.path().to_string_lossy().into_owned(); @@ -2189,6 +2268,23 @@ mod tests { assert!(runtime.block_on(read_local_image(missing)).is_err()); } + #[test] + fn local_path_read_rejects_oversized_and_non_regular_paths() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + file.write_all(&[1, 2, 3]).expect("write temp image"); + let path = file.path().to_string_lossy(); + assert!(read_local_image_file(&path, 2) + .expect_err("oversized file must fail") + .contains("exceeds")); + + let directory = tempfile::tempdir().expect("temp directory"); + let directory_path = directory.path().to_string_lossy(); + assert_eq!( + read_local_image_file(&directory_path, 16).expect_err("directory must fail"), + "thumbnail path is not a regular file" + ); + } + fn key(s: &str, p: &str) -> MediaKey { MediaKey::new(s, p) } diff --git a/rust/frontend/src/media_meta_cache.rs b/rust/frontend/src/media_meta_cache.rs index 8800a956..d3100c4e 100644 --- a/rust/frontend/src/media_meta_cache.rs +++ b/rust/frontend/src/media_meta_cache.rs @@ -23,13 +23,16 @@ // byte-accounted under a hard 4 MiB cap. use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::mem::size_of; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Instant; +use std::time::{Duration, Instant}; +use tokio::sync::Semaphore; use tracing::debug; +use zaparoo_core::client::ClientError; use zaparoo_core::media_types::{ - MediaMeta, MediaMetaBatchResult, MediaMetaParams, MediaMetaProperty, TagInfo, + MediaMeta, MediaMetaBatchResult, MediaMetaParams, MediaMetaProperty, MediaMetaResult, TagInfo, MEDIA_META_BATCH_MAX_ITEMS, }; @@ -37,6 +40,9 @@ use crate::media_image_cache::MediaKey; use crate::models::{global_handle, global_store}; const META_CACHE_CAP_BYTES: usize = 4 * 1024 * 1024; +const META_RPC_MAX_CONCURRENT: usize = 4; +const META_RPC_QUEUE_TIMEOUT: Duration = Duration::from_secs(2); +const META_RPC_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const HASH_ENTRY_OVERHEAD: usize = 16; /// Outcome of a synchronous cache probe. @@ -130,6 +136,22 @@ impl MediaMetaCache { store_locked(&mut guard, key, meta); } + /// Cache a focused fetch only when it produced metadata or a stable + /// canonical path miss. Transport failures and stale session IDs must stay + /// retryable instead of becoming permanent `(system, path)` negatives. + pub fn store_fetch_result(&self, key: MediaKey, result: &Result) { + match result { + Ok(result) => self.store(key, Some(result.media.clone())), + Err(error) if is_stable_path_not_found(&error.message) => self.store(key, None), + Err(error) => debug!( + system_id = %key.system_id, + path = %key.path, + error = %error.message, + "media_meta_cache: transient focused failure left retryable" + ), + } + } + /// Best-effort background warm of uncached neighbors. One ordered batch is /// issued after cached/in-flight filtering; results become synchronous hits /// for the next focus move. @@ -139,13 +161,15 @@ impl MediaMetaCache { return; } global_handle().spawn(async move { - let (keys, params): (Vec<_>, Vec<_>) = to_fetch.into_iter().unzip(); - let batch_size = keys.len(); + let batch_size = to_fetch.len(); let started = Instant::now(); - let result = global_store().client().media_meta_batch(params).await; + let client = global_store().client(); + let params = to_fetch.iter().map(|(_, params)| params.clone()).collect(); + let result = run_bounded_meta_rpc(client.media_meta_batch(params)).await; let cache = global_media_meta_cache(); match result { - Ok(batch) => { + Ok(mut batch) => { + retry_stale_media_ids(&client, &to_fetch, &mut batch).await; let hits = batch .items .iter() @@ -163,7 +187,10 @@ impl MediaMetaCache { duration_ms = started.elapsed().as_millis(), "media_meta_cache: batch prefetch complete" ); - cache.finish_prefetch(keys, Some(batch)); + cache.finish_prefetch( + to_fetch.into_iter().map(|(key, _)| key).collect(), + Some(batch), + ); } Err(error) => { debug!( @@ -172,7 +199,7 @@ impl MediaMetaCache { error = %error.message, "media_meta_cache: batch prefetch failed" ); - cache.finish_prefetch(keys, None); + cache.finish_prefetch(to_fetch.into_iter().map(|(key, _)| key).collect(), None); } } }); @@ -219,7 +246,18 @@ impl MediaMetaCache { for (key, item) in keys.into_iter().zip(batch.items) { match (item.media, item.error) { (Some(meta), None) => store_locked(&mut guard, key, Some(meta)), - (None, Some(_)) => store_locked(&mut guard, key, None), + (None, Some(error)) if is_stable_path_not_found(&error) => { + store_locked(&mut guard, key, None); + } + (None, Some(error)) => { + guard.inflight.remove(&key); + debug!( + system_id = %key.system_id, + path = %key.path, + error, + "media_meta_cache: transient batch item left retryable" + ); + } _ => { guard.inflight.remove(&key); debug!("media_meta_cache: unmatched batch item"); @@ -229,6 +267,132 @@ impl MediaMetaCache { } } +/// Retry only errors that prove a session-scoped ID went stale. Other errors +/// remain untouched so the normal transient-failure path can release them. +async fn retry_stale_media_ids( + client: &zaparoo_core::client::Client, + requests: &[(MediaKey, MediaMetaParams)], + batch: &mut MediaMetaBatchResult, +) { + if batch.items.len() != requests.len() { + return; + } + let retries: Vec<_> = requests + .iter() + .zip(&batch.items) + .enumerate() + .filter_map(|(index, ((key, params), item))| { + let error = item.error.as_deref()?; + (params.media_id.is_some() && is_stale_media_id_error(error)).then(|| { + ( + index, + MediaMetaParams::for_media( + key.system_id.as_ref().to_owned(), + key.path.as_ref().to_owned(), + ), + ) + }) + }) + .collect(); + if retries.is_empty() { + return; + } + let retry_params = retries.iter().map(|(_, params)| params.clone()).collect(); + match run_bounded_meta_rpc(client.media_meta_batch(retry_params)).await { + Ok(retry_batch) if retry_batch.items.len() == retries.len() => { + for ((index, _), item) in retries.into_iter().zip(retry_batch.items) { + batch.items[index] = item; + } + } + Ok(retry_batch) => debug!( + expected = retries.len(), + actual = retry_batch.items.len(), + "media_meta_cache: malformed stale-ID fallback response" + ), + Err(error) => debug!( + error = %error.message, + "media_meta_cache: stale-ID path fallback failed" + ), + } +} + +/// Fetch a focused item by its fast session ID, falling back once to the +/// canonical path only when Core confirms that ID no longer exists. +pub async fn fetch_media_meta_with_path_fallback( + params: MediaMetaParams, + system: String, + path: String, +) -> Result { + let client = global_store().client(); + let used_media_id = params.media_id.is_some(); + match run_bounded_meta_rpc(client.media_meta(params)).await { + Err(error) if used_media_id && is_stale_media_id_error(&error.message) => { + debug!( + system_id = %system, + path = %path, + error = %error.message, + "media_meta_cache: retrying stale media ID by canonical path" + ); + run_bounded_meta_rpc(client.media_meta(MediaMetaParams::for_media(system, path))).await + } + result => result, + } +} + +static META_RPC_SEMAPHORE: OnceLock> = OnceLock::new(); + +async fn run_bounded_meta_rpc( + operation: impl Future>, +) -> Result { + let semaphore = META_RPC_SEMAPHORE + .get_or_init(|| Arc::new(Semaphore::new(META_RPC_MAX_CONCURRENT))) + .clone(); + run_bounded_meta_rpc_with( + semaphore, + META_RPC_QUEUE_TIMEOUT, + META_RPC_RESPONSE_TIMEOUT, + operation, + ) + .await +} + +async fn run_bounded_meta_rpc_with( + semaphore: Arc, + queue_timeout: Duration, + response_timeout: Duration, + operation: impl Future>, +) -> Result { + let permit = tokio::time::timeout(queue_timeout, semaphore.acquire_owned()) + .await + .map_err(|_| ClientError { + message: "media metadata request queue timed out".into(), + })? + .map_err(|_| ClientError { + message: "media metadata request queue closed".into(), + })?; + let result = tokio::time::timeout(response_timeout, operation) + .await + .map_err(|_| ClientError { + message: "media metadata response timed out".into(), + })?; + drop(permit); + result +} + +fn is_stale_media_id_error(message: &str) -> bool { + message + .trim() + .to_ascii_lowercase() + .starts_with("media not found: mediaid ") +} + +fn is_stable_path_not_found(message: &str) -> bool { + let normalized = message.trim().to_ascii_lowercase(); + normalized.starts_with("system not found:") + || (normalized.starts_with("media not found:") + && !normalized.starts_with("media not found: mediaid ")) +} + fn clear_inflight_locked(guard: &mut State, keys: &[MediaKey]) { for key in keys { guard.inflight.remove(key); @@ -399,6 +563,42 @@ mod tests { prepared.iter().map(|(key, _)| key.clone()).collect() } + #[tokio::test] + #[allow(clippy::unwrap_used, reason = "test semaphore must remain open")] + async fn bounded_rpc_times_out_while_waiting_for_capacity() { + let semaphore = Arc::new(Semaphore::new(1)); + let _held = semaphore.clone().acquire_owned().await.unwrap(); + let result = run_bounded_meta_rpc_with( + semaphore, + Duration::from_millis(10), + Duration::from_secs(1), + async { Ok::<_, ClientError>(()) }, + ) + .await; + assert!(matches!( + result, + Err(ClientError { message }) if message == "media metadata request queue timed out" + )); + } + + #[tokio::test] + async fn bounded_rpc_times_out_stalled_response() { + let result = run_bounded_meta_rpc_with( + Arc::new(Semaphore::new(1)), + Duration::from_secs(1), + Duration::from_millis(10), + async { + std::future::pending::<()>().await; + Ok::<_, ClientError>(()) + }, + ) + .await; + assert!(matches!( + result, + Err(ClientError { message }) if message == "media metadata response timed out" + )); + } + #[test] fn positive_hit_round_trips() { let cache = MediaMetaCache::new(); @@ -492,6 +692,64 @@ mod tests { assert!(matches!(cache.lookup(&key("b")), MetaLookup::Negative)); } + #[test] + fn transient_batch_item_error_releases_without_poisoning() { + let cache = MediaMetaCache::new(); + let prepared = cache.prepare_prefetch(vec![(key("a"), params("a"))]); + cache.finish_prefetch( + prepared_keys(&prepared), + Some(MediaMetaBatchResult { + items: vec![MediaMetaBatchItemResult { + media: None, + error: Some("database temporarily unavailable".into()), + }], + }), + ); + assert!(matches!(cache.lookup(&key("a")), MetaLookup::Miss)); + assert_eq!( + cache.prepare_prefetch(vec![(key("a"), params("a"))]).len(), + 1 + ); + } + + #[test] + fn focused_fetch_only_memoizes_stable_path_misses() { + let cache = MediaMetaCache::new(); + let transient_key = key("transient"); + cache.store_fetch_result( + transient_key.clone(), + &Err(ClientError { + message: "not connected".into(), + }), + ); + assert!(matches!(cache.lookup(&transient_key), MetaLookup::Miss)); + + let stale_id_key = key("stale-id"); + cache.store_fetch_result( + stale_id_key.clone(), + &Err(ClientError { + message: "media not found: mediaId 42".into(), + }), + ); + assert!(matches!(cache.lookup(&stale_id_key), MetaLookup::Miss)); + + let missing_key = key("missing"); + cache.store_fetch_result( + missing_key.clone(), + &Err(ClientError { + message: "media not found: SNES/missing".into(), + }), + ); + assert!(matches!(cache.lookup(&missing_key), MetaLookup::Negative)); + } + + #[test] + fn stale_media_id_error_classifier_is_specific() { + assert!(is_stale_media_id_error("media not found: mediaId 42")); + assert!(!is_stale_media_id_error("media not found: SNES/game")); + assert!(!is_stale_media_id_error("not connected")); + } + #[test] fn transport_and_short_responses_release_without_poisoning() { let cache = MediaMetaCache::new(); diff --git a/rust/frontend/src/mister_runtime.rs b/rust/frontend/src/mister_runtime.rs index 5f93e0aa..a6fa3567 100644 --- a/rust/frontend/src/mister_runtime.rs +++ b/rust/frontend/src/mister_runtime.rs @@ -80,27 +80,49 @@ fn automatic_render_size(width: u32, height: u32) -> (u32, u32) { #[cfg(zaparoo_runtime = "mister")] fn probe_automatic_render_size() -> Option<(u32, u32)> { - run_vmode_scale("f", "rgb32").ok()?; + use tracing::warn; + + let full_result = run_vmode_scale("f", "rgb32"); let full_size = current_framebuffer_size()?; - if full_size.1 <= FULL_SIZE_MAX_HEIGHT { + if full_result.is_ok() && full_size.1 <= FULL_SIZE_MAX_HEIGHT { return Some(full_size); } - - // Let Main derive half scale from active HDMI timing. Unlike dividing an - // inherited framebuffer, this remains correct when the previous process - // left fb0 at an unrelated size. - run_vmode_scale("h", "rgb32").ok()?; - if let Some(scaled_size) = current_framebuffer_size() { - if scaled_size != full_size { - return Some(scaled_size); + if let Err(error) = full_result { + warn!(%error, "full-scale vmode probe unsupported; using explicit fallback"); + } else { + // Let Main derive half scale from active HDMI timing. Unlike dividing an + // inherited framebuffer, this remains correct when the previous process + // left fb0 at an unrelated size. + match run_vmode_scale("h", "rgb32") { + Ok(()) => { + if let Some(scaled_size) = current_framebuffer_size() { + if scaled_size != full_size { + return Some(scaled_size); + } + } + } + Err(error) => warn!(%error, "half-scale vmode probe unsupported"), } } // Older vmode/Main pairs may accept only explicit geometry. Keep this as a - // compatibility fallback, then trust sysfs for what was actually applied. - let (width, height) = automatic_render_size(full_size.0, full_size.1); - run_vmode_with_format(width, height, "rgb32").ok()?; - current_framebuffer_size().or(Some(full_size)) + // compatibility fallback, then verify sysfs instead of treating process + // creation as proof that the command was understood. + let target = automatic_render_size(full_size.0, full_size.1); + run_vmode_with_format(target.0, target.1, "rgb32").ok()?; + let applied = current_framebuffer_size()?; + if applied == target { + Some(applied) + } else { + warn!( + expected_width = target.0, + expected_height = target.1, + actual_width = applied.0, + actual_height = applied.1, + "explicit vmode fallback did not apply requested geometry" + ); + None + } } #[cfg(zaparoo_runtime = "mister")] @@ -179,12 +201,37 @@ fn run_vmode_with_format(width: u32, height: u32, pixel_format: &str) -> std::io #[cfg(zaparoo_runtime = "mister")] fn run_vmode_command(mut command: std::process::Command) -> std::io::Result<()> { - use std::process::Stdio; - // MiSTer's vmode script returns 1 both when res_count confirms a change and - // when its bounded wait expires. Geometry from sysfs is authoritative. - command.stdout(Stdio::null()).stderr(Stdio::null()); - command.status().map(|_| ()) + // when its bounded wait expires. Geometry from sysfs remains authoritative, + // but usage output or any other exit code means the command was not accepted. + let output = command.output()?; + if vmode_result_accepted(output.status.code(), &output.stdout, &output.stderr) { + Ok(()) + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "vmode rejected command (exit {:?}): {}{}", + output.status.code(), + stdout.trim(), + stderr.trim() + ), + )) + } +} + +#[cfg(any(zaparoo_runtime = "mister", test))] +fn vmode_result_accepted(code: Option, stdout: &[u8], stderr: &[u8]) -> bool { + let output = format!( + "{}\n{}", + String::from_utf8_lossy(stdout), + String::from_utf8_lossy(stderr) + ) + .to_ascii_lowercase(); + let reports_usage = output.contains("usage:") || output.contains("unknown format"); + !reports_usage && matches!(code, Some(0 | 1)) } #[cfg(any(zaparoo_runtime = "mister", test))] @@ -220,7 +267,7 @@ pub fn ensure_core_service_running() { mod tests { use super::{ automatic_render_size, core_service_start_command, parse_fb_mode, parse_virtual_size, - vmode_resolution_command, vmode_scale_command, + vmode_resolution_command, vmode_result_accepted, vmode_scale_command, }; #[test] @@ -277,6 +324,28 @@ mod tests { ); } + #[test] + fn accepts_mister_vmode_success_and_ambiguous_exit_one() { + assert!(vmode_result_accepted(Some(0), b"", b"")); + assert!(vmode_result_accepted(Some(1), b".... failed!", b"")); + } + + #[test] + fn rejects_usage_output_and_other_exit_codes() { + assert!(!vmode_result_accepted( + Some(0), + b"usage:\n vmode -r width height format", + b"" + )); + assert!(!vmode_result_accepted( + Some(0), + b"error: unknown format", + b"" + )); + assert!(!vmode_result_accepted(Some(2), b"", b"")); + assert!(!vmode_result_accepted(None, b"", b"terminated")); + } + #[test] fn core_service_starts_with_both_mister_cpus() { let command = core_service_start_command(); diff --git a/rust/frontend/src/models/favorites.rs b/rust/frontend/src/models/favorites.rs index fe31cb29..14305d3a 100644 --- a/rust/frontend/src/models/favorites.rs +++ b/rust/frontend/src/models/favorites.rs @@ -15,7 +15,9 @@ // set for sorting. use crate::media_image_cache::{global_media_image_cache, MediaImageCache, MediaKey}; -use crate::media_meta_cache::{global_media_meta_cache, MetaLookup}; +use crate::media_meta_cache::{ + fetch_media_meta_with_path_fallback, global_media_meta_cache, MetaLookup, +}; use crate::models::nav_timing::NavTiming; use crate::models::tag_utils::{ disambiguating_tag_labels, sibling_disambiguation_displays, tag_display_value, @@ -985,20 +987,18 @@ impl ffi::FavoritesModel { self.as_mut().set_current_detail_tags(QString::default()); let seq = self.rust().detail_seq.clone(); let qt_thread = self.qt_thread(); - let store = global_store(); let store_key = meta_key.clone(); + let fallback_system = system.clone(); + let fallback_path = path.clone(); let meta_params = media_id.map_or_else( || MediaMetaParams::for_media(system, path.clone()), MediaMetaParams::for_media_id, ); global_handle().spawn(async move { - let result = store.client().media_meta(meta_params).await; - // Cache the outcome (positive or negative) regardless of whether - // this callback is still current, so a later revisit is instant. - match &result { - Ok(r) => global_media_meta_cache().store(store_key, Some(r.media.clone())), - Err(_) => global_media_meta_cache().store(store_key, None), - } + let result = + fetch_media_meta_with_path_fallback(meta_params, fallback_system, fallback_path) + .await; + global_media_meta_cache().store_fetch_result(store_key, &result); let _ = qt_thread.queue(move |mut model| { if seq.load(Ordering::SeqCst) != ticket { return; diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 94420dcf..868afc4d 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -31,7 +31,9 @@ // when the user spams direction-arrow + Accept across a model swap. use crate::media_image_cache::{global_media_image_cache, MediaImageCache, MediaKey}; -use crate::media_meta_cache::{global_media_meta_cache, MetaLookup}; +use crate::media_meta_cache::{ + fetch_media_meta_with_path_fallback, global_media_meta_cache, MetaLookup, +}; use crate::models::nav_timing::NavTiming; use crate::models::tag_utils::{ disambiguating_tag_labels, sibling_disambiguation_displays, tag_display_value, @@ -1306,10 +1308,12 @@ impl ffi::GamesModel { let description = entry.description.clone(); let detail_tags = detail_tags_from_entry(entry); + let fallback_system = entry_system_id(entry); + let fallback_path = entry.path.clone(); // Use the cover-preference key as the synchronous primary so the detail // pane requests the same cache entry that `prefetch_around` already // warmed for the focused row — instant paint with no hourglass. - let detail_image_key = media_key_for(entry).map(MediaKey::with_current_cover_preference); + let detail_image_key = detail_cover_key_for_entry(entry); let meta_key = meta_cache_key_for_entry(entry); let Some(params) = meta_params_for_entry(entry) else { self.as_mut().set_current_detail_loading(false); @@ -1347,16 +1351,11 @@ impl ffi::GamesModel { let seq = self.rust().description_seq.clone(); let qt_thread = self.qt_thread(); - let store = global_store(); global_handle().spawn(async move { - let result = store.client().media_meta(params).await; + let result = + fetch_media_meta_with_path_fallback(params, fallback_system, fallback_path).await; if let Some(key) = meta_key { - match &result { - Ok(result) => { - global_media_meta_cache().store(key, Some(result.media.clone())); - } - Err(_) => global_media_meta_cache().store(key, None), - } + global_media_meta_cache().store_fetch_result(key, &result); } let _ = qt_thread.queue(move |mut model| { if seq.load(Ordering::SeqCst) != ticket { @@ -1919,11 +1918,15 @@ fn apply_games_detail_meta(mut model: Pin<&mut ffi::GamesModel>, index: i32, met .as_mut() .set_current_detail_tags(QString::from(detail_tags_from_meta(meta).as_str())); - let cover_key = usize::try_from(index) + let entry = usize::try_from(index) .ok() - .and_then(|index| model.entries.get(index)) - .and_then(media_key_for) - .map(MediaKey::with_current_cover_preference); + .and_then(|index| model.entries.get(index)); + if !entry.is_some_and(|entry| entry.has_cover) { + model.as_mut().rust_mut().pending_carousel_keys = None; + set_detail_image_keys(model, Vec::new()); + return; + } + let cover_key = entry.and_then(detail_cover_key_for_entry); let type_keys = detail_image_keys_from_meta(meta, meta.title.system.id.as_str(), meta.path.as_str()); if type_keys.is_empty() { @@ -2390,6 +2393,13 @@ fn media_key_for(entry: &BrowseEntry) -> Option { } } +fn detail_cover_key_for_entry(entry: &BrowseEntry) -> Option { + entry + .has_cover + .then(|| media_key_for(entry).map(MediaKey::with_current_cover_preference)) + .flatten() +} + /// Pure ordering helper for `prefetch_around`. Returns /// (`MediaKey`, `media_id`) pairs in desired fetch order: current page /// top-to-bottom, then configured next pages, then configured previous @@ -3380,14 +3390,11 @@ fn apply_append_page( let total = i32::try_from(result.total_files).unwrap_or(i32::MAX); // Order matters for the pending-target chain in PagedGrid: // - // 1. `next_cursor` and `loading_more=false` MUST happen - // before the FIRST sub-batch's `end_insert_rows`. The - // Repeater reacts synchronously to `rowsInserted`; - // PagedGrid's `onItemCountChanged` runs - // `_commitPendingTarget`, which can re-fire - // `loadMoreRequested` -> `fetch_more`. If `loading_more` - // is still true at that moment the `fetch_more` guard - // early-returns and the chain stalls after one append. + // 1. Keep `loading_more=true` until the LAST sub-batch lands. The + // grid suppresses another cursor request while this flag is set, + // preventing a later page from interleaving with this page's + // frame-gapped tail. Its `onLoadingMoreChanged` handler resumes + // any still-pending target once finalization clears the flag. // // 2. `has_next_page` MUST happen after the LAST sub-batch's // `end_insert_rows`. On the final chunk the value flips @@ -3408,7 +3415,6 @@ fn apply_append_page( // self-disarm via the `append_seq` ticket if a new // `start_initial_browse` lands during the trickle window. model.as_mut().rust_mut().next_cursor = next_cursor; - model.as_mut().set_loading_more(false); // Jump path: insert the whole chunk in one shot, no frame-gapped // trickle. The appended rows sit far from `currentPage`, so their // Tile delegates stay unmaterialised (per-cell Loader is @@ -3437,6 +3443,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } + model.as_mut().set_loading_more(false); return; } let mut batches = chunk_for_subbatching(entries, APPEND_SUB_BATCH_SIZE); @@ -3447,6 +3454,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } + model.as_mut().set_loading_more(false); return; } // First batch runs synchronously inside the existing @@ -3468,6 +3476,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } + model.as_mut().set_loading_more(false); return; } // Remaining batches: post one frame apart on the Qt @@ -3502,6 +3511,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } + model.as_mut().set_loading_more(false); } }); } @@ -3538,13 +3548,13 @@ mod tests { use super::{ child_launch_text_from_browse_result, chunk_for_subbatching, compute_unresolved_keys, cover_key_for_with, cover_placeholder_for, decide_initial, dedup_roots_drop_ancestors, - detail_image_keys_from_meta, detail_tags_from_tags, display_name, display_title_for_entry, - entry_system_id, favorites_tags, games_random_launch_text, initial_row_replacement, - is_media_capable_entry, is_strict_ancestor_path, jump_fetch_limit, - media_capable_directory_browse_params, media_key_for, meta_cache_key_for_entry, - meta_params_for_entry, ordered_detail_image_keys, position_of_game_path, - prefetch_around_plan, prefetch_cursor_window_plan, project_status, result_total_dirs, - run_text_for_entry, seeded_refetch_pagination_state, + detail_cover_key_for_entry, detail_image_keys_from_meta, detail_tags_from_tags, + display_name, display_title_for_entry, entry_system_id, favorites_tags, + games_random_launch_text, initial_row_replacement, is_media_capable_entry, + is_strict_ancestor_path, jump_fetch_limit, media_capable_directory_browse_params, + media_key_for, meta_cache_key_for_entry, meta_params_for_entry, ordered_detail_image_keys, + position_of_game_path, prefetch_around_plan, prefetch_cursor_window_plan, project_status, + result_total_dirs, run_text_for_entry, seeded_refetch_pagination_state, singleton_directory_needs_launch_resolution, transform_entries, InitialAction, InitialRowReplacement, Projection, }; @@ -4441,6 +4451,16 @@ mod tests { assert!(unresolved.is_empty()); } + #[test] + fn detail_cover_key_excludes_confirmed_no_cover_entry() { + let mut no_cover = media("nocovergame", "/p/nocovergame", "Arcade"); + no_cover.has_cover = false; + assert!(detail_cover_key_for_entry(&no_cover).is_none()); + + let covered = media("coveredgame", "/p/coveredgame", "NES"); + assert!(detail_cover_key_for_entry(&covered).is_some()); + } + #[test] fn compute_unresolved_keys_excludes_no_cover_entries() { // Core sends has_cover=false for entries with no image property row. diff --git a/rust/frontend/src/models/recents.rs b/rust/frontend/src/models/recents.rs index eb5618e8..62f0730d 100644 --- a/rust/frontend/src/models/recents.rs +++ b/rust/frontend/src/models/recents.rs @@ -24,7 +24,9 @@ // recents launches by `run`-ing the entry's launcher route. use crate::media_image_cache::{global_media_image_cache, MediaImageCache, MediaKey}; -use crate::media_meta_cache::{global_media_meta_cache, MetaLookup}; +use crate::media_meta_cache::{ + fetch_media_meta_with_path_fallback, global_media_meta_cache, MetaLookup, +}; use crate::models::nav_timing::NavTiming; use crate::models::tag_utils::tag_display_value; use crate::models::{global_handle, global_store}; @@ -349,6 +351,15 @@ fn page_snapshot(result: &MediaHistoryResult) -> PageSnapshot { ) } +fn history_page_params(cursor: Option) -> MediaHistoryParams { + MediaHistoryParams { + limit: Some(PAGE_SIZE), + cursor, + systems: Vec::new(), + distinct_media: Some(true), + } +} + fn apply_state( mut model: Pin<&mut ffi::RecentsModel>, (data, err): (Option, String), @@ -642,12 +653,7 @@ impl ffi::RecentsModel { global_handle().spawn(async move { let result = store .client() - .media_history(MediaHistoryParams { - limit: Some(PAGE_SIZE), - cursor: None, - systems: Vec::new(), - distinct_media: Some(true), - }) + .media_history(history_page_params(None)) .await; match &result { Ok(r) => info!( @@ -724,12 +730,7 @@ impl ffi::RecentsModel { global_handle().spawn(async move { let result = store .client() - .media_history(MediaHistoryParams { - limit: Some(PAGE_SIZE), - cursor, - systems: Vec::new(), - distinct_media: Some(true), - }) + .media_history(history_page_params(cursor)) .await; let _ = qt_thread.queue(move |model| { if seq.load(Ordering::SeqCst) != ticket { @@ -947,20 +948,18 @@ impl ffi::RecentsModel { self.as_mut().set_current_detail_tags(QString::default()); let seq = self.rust().detail_seq.clone(); let qt_thread = self.qt_thread(); - let store = global_store(); let store_key = meta_key.clone(); + let fallback_system = system.clone(); + let fallback_path = path.clone(); let meta_params = media_id.map_or_else( || MediaMetaParams::for_media(system, path.clone()), MediaMetaParams::for_media_id, ); global_handle().spawn(async move { - let result = store.client().media_meta(meta_params).await; - // Cache the outcome (positive or negative) regardless of whether - // this callback is still current, so a later revisit is instant. - match &result { - Ok(r) => global_media_meta_cache().store(store_key, Some(r.media.clone())), - Err(_) => global_media_meta_cache().store(store_key, None), - } + let result = + fetch_media_meta_with_path_fallback(meta_params, fallback_system, fallback_path) + .await; + global_media_meta_cache().store_fetch_result(store_key, &result); let _ = qt_thread.queue(move |mut model| { if seq.load(Ordering::SeqCst) != ticket { return; @@ -1796,8 +1795,8 @@ mod tests { use super::{ compute_unresolved_keys, cover_key_for_with, dedupe_latest_by_identity, - filter_entries_by_identity, launch_text_for, media_key_for, page_snapshot, - position_of_path, resume_cover_key_for, resume_entry, resume_entry_is_fresh, + filter_entries_by_identity, history_page_params, launch_text_for, media_key_for, + page_snapshot, position_of_path, resume_cover_key_for, resume_entry, resume_entry_is_fresh, RESUME_FALLBACK_COVER_KEY, }; use crate::media_image_cache::{MediaImageCache, MediaKey}; @@ -2065,6 +2064,17 @@ mod tests { assert_eq!(position_of_path(&entries, "/missing"), -1); } + #[test] + fn history_page_params_preserve_distinct_media_for_every_cursor() { + let initial = history_page_params(None); + assert_eq!(initial.distinct_media, Some(true)); + assert!(initial.cursor.is_none()); + + let continuation = history_page_params(Some("cursor-2".into())); + assert_eq!(continuation.distinct_media, Some(true)); + assert_eq!(continuation.cursor.as_deref(), Some("cursor-2")); + } + #[test] fn page_snapshot_carries_entries_and_pagination() { let result = MediaHistoryResult { diff --git a/rust/mock-core/src/fixtures.rs b/rust/mock-core/src/fixtures.rs index 65a069c2..ce4c24d6 100644 --- a/rust/mock-core/src/fixtures.rs +++ b/rust/mock-core/src/fixtures.rs @@ -434,16 +434,35 @@ pub fn media_history_response(params: &Value) -> Value { .and_then(Value::as_u64) .unwrap_or(25) .min(100) as usize; + let offset = params + .get("cursor") + .and_then(Value::as_str) + .and_then(|cursor| cursor.parse::().ok()) + .unwrap_or(0); + let distinct_media = params + .get("distinctMedia") + .and_then(Value::as_bool) + .unwrap_or(false); - // Synthesize a history list from the first ten games in `ALL_GAMES`, - // newest first. Real Core sorts by `endedAt` descending; the mock - // just walks the array and stamps backward-counting timestamps so - // the order is stable across runs. - let entries: Vec = ALL_GAMES + // Synthesize newest-first play sessions. Non-distinct history repeats the + // newest game once so tests can prove `distinctMedia` changes semantics; + // distinct history exposes one row per `(systemId, mediaPath)` before the + // cursor is applied, matching Core's pagination contract. + let mut sessions: Vec<_> = ALL_GAMES .iter() .filter(|(_, _, system)| systems.is_empty() || systems.contains(system)) - .take(limit) + .collect(); + if !distinct_media { + if let Some(newest) = sessions.first().copied() { + sessions.insert(1, newest); + } + } + let total = sessions.len(); + let entries: Vec = sessions + .into_iter() .enumerate() + .skip(offset) + .take(limit) .map(|(i, (name, file, system))| { let started = format!("2026-04-29T{:02}:00:00Z", 23 - i.min(23)); let ended = format!("2026-04-29T{:02}:30:00Z", 23 - i.min(23)); @@ -464,12 +483,17 @@ pub fn media_history_response(params: &Value) -> Value { // returned; mirror that so the frontend's MediaHistoryResult // deserialiser hits the same edges in mock as on real Core. let has_entries = !entries.is_empty(); + let next_offset = offset.saturating_add(entries.len()); let mut response = json!({ "entries": entries }); if has_entries { + let has_next_page = next_offset < total; response["pagination"] = json!({ - "hasNextPage": false, + "hasNextPage": has_next_page, "pageSize": limit, }); + if has_next_page { + response["pagination"]["nextCursor"] = json!(next_offset.to_string()); + } } response } diff --git a/rust/mock-core/src/handler.rs b/rust/mock-core/src/handler.rs index 4bf174cd..7babfd3b 100644 --- a/rust/mock-core/src/handler.rs +++ b/rust/mock-core/src/handler.rs @@ -394,12 +394,12 @@ mod tests { } #[test] - fn media_history_returns_entries_with_pagination() { - let req = r#"{"jsonrpc":"2.0","id":"1","method":"media.history","params":{"limit":5,"distinctMedia":true}}"#; - let resp = parse(&dispatch(req)); - let entries = resp["result"]["entries"].as_array().expect("array"); - assert!(!entries.is_empty()); - for entry in entries { + fn media_history_returns_cursor_pages() { + let first_req = r#"{"jsonrpc":"2.0","id":"1","method":"media.history","params":{"limit":5,"distinctMedia":true}}"#; + let first = parse(&dispatch(first_req)); + let first_entries = first["result"]["entries"].as_array().expect("array"); + assert_eq!(first_entries.len(), 5); + for entry in first_entries { assert!(entry["mediaName"].is_string()); assert!(entry["mediaPath"].is_string()); assert!(entry["systemId"].is_string()); @@ -407,10 +407,42 @@ mod tests { assert!(entry["launcherId"].is_string()); assert!(entry["hasCover"].is_boolean()); } - let pagination = resp["result"]["pagination"] + let pagination = first["result"]["pagination"] .as_object() .expect("pagination object"); - assert_eq!(pagination["hasNextPage"], Value::Bool(false)); + assert_eq!(pagination["hasNextPage"], Value::Bool(true)); + assert_eq!(pagination["nextCursor"], Value::String("5".into())); + + let next_req = r#"{"jsonrpc":"2.0","id":"2","method":"media.history","params":{"limit":5,"cursor":"5","distinctMedia":true}}"#; + let next = parse(&dispatch(next_req)); + let next_entries = next["result"]["entries"].as_array().expect("array"); + assert!(!next_entries.is_empty()); + let first_paths = first_entries + .iter() + .map(|entry| entry["mediaPath"].as_str().expect("first path")) + .collect::>(); + assert!(next_entries.iter().all(|entry| { + !first_paths.contains(entry["mediaPath"].as_str().expect("next path")) + })); + } + + #[test] + fn media_history_honors_distinct_media_before_paging() { + let repeated_req = r#"{"jsonrpc":"2.0","id":"1","method":"media.history","params":{"limit":2,"distinctMedia":false}}"#; + let repeated = parse(&dispatch(repeated_req)); + let repeated_entries = repeated["result"]["entries"].as_array().expect("array"); + assert_eq!( + repeated_entries[0]["mediaPath"], + repeated_entries[1]["mediaPath"] + ); + + let distinct_req = r#"{"jsonrpc":"2.0","id":"2","method":"media.history","params":{"limit":2,"distinctMedia":true}}"#; + let distinct = parse(&dispatch(distinct_req)); + let distinct_entries = distinct["result"]["entries"].as_array().expect("array"); + assert_ne!( + distinct_entries[0]["mediaPath"], + distinct_entries[1]["mediaPath"] + ); } #[test] diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index b7eed4e8..c7aa3ad5 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -160,6 +160,20 @@ impl std::error::Error for ClientError {} type PendingMap = Arc>>>>; +/// Removes a pending RPC when its `call()` future is canceled or times out. +/// Normal responses remove the same key first, making this drop a no-op. +struct PendingRequestGuard { + id: String, + pending: PendingMap, +} + +impl Drop for PendingRequestGuard { + #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] + fn drop(&mut self) { + self.pending.lock().unwrap().remove(&self.id); + } +} + fn deserialize_timed( method: &'static str, val: Value, @@ -471,6 +485,10 @@ impl Client { { self.pending.lock().unwrap().insert(id.clone(), resp_tx); } + let _pending_guard = PendingRequestGuard { + id: id.clone(), + pending: self.pending.clone(), + }; if sender.send(text).is_err() { // Receiver was dropped between the snapshot and the send — @@ -895,6 +913,21 @@ pub(crate) fn backoff_delay(failures: u32, boot_window: bool) -> Duration { mod tests { use super::*; + #[test] + #[allow(clippy::unwrap_used, reason = "test mutex must remain healthy")] + fn pending_request_guard_cleans_up_canceled_call() { + let pending = PendingMap::default(); + let (sender, _receiver) = oneshot::channel(); + pending.lock().unwrap().insert("request".into(), sender); + { + let _guard = PendingRequestGuard { + id: "request".into(), + pending: pending.clone(), + }; + } + assert!(pending.lock().unwrap().is_empty()); + } + #[test] fn backoff_follows_exponential_curve_then_caps() { assert_eq!(backoff_delay(0, false), Duration::from_secs(1)); diff --git a/src/app/frontend_arguments.cpp b/src/app/frontend_arguments.cpp new file mode 100644 index 00000000..6592776c --- /dev/null +++ b/src/app/frontend_arguments.cpp @@ -0,0 +1,55 @@ +// Zaparoo Frontend +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 + +#include "frontend_arguments.h" + +#include +#include +#include +#include + +namespace zaparoo +{ +ParsedArguments parseArguments(int argc, char* argv[]) +{ + ParsedArguments parsed; + parsed.argv.reserve(static_cast(argc)); + std::copy_n(argv, argc, std::back_inserter(parsed.argv)); + parsed.originalArgv = parsed.argv; + parsed.originalArgv.push_back(nullptr); + + std::vector filtered; + filtered.reserve(parsed.argv.size() + 1); + if (!parsed.argv.empty()) + { + filtered.push_back(parsed.argv.front()); + } + + bool optionsEnded = false; + for (size_t i = 1; i < parsed.argv.size(); ++i) + { + char* arg = parsed.argv.at(i); + if (!optionsEnded && std::strcmp(arg, "--") == 0) + { + optionsEnded = true; + filtered.push_back(arg); + continue; + } + if (!optionsEnded && std::strcmp(arg, "--crt") == 0) + { + parsed.crtNativePathForced = true; + continue; + } + if (!optionsEnded && (std::strcmp(arg, "--version") == 0 || std::strcmp(arg, "-v") == 0)) + { + parsed.versionRequested = true; + } + filtered.push_back(arg); + } + + parsed.argv = std::move(filtered); + parsed.argv.push_back(nullptr); + return parsed; +} +} // namespace zaparoo diff --git a/src/app/frontend_arguments.h b/src/app/frontend_arguments.h new file mode 100644 index 00000000..9a58ef4d --- /dev/null +++ b/src/app/frontend_arguments.h @@ -0,0 +1,22 @@ +// Zaparoo Frontend +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 + +#pragma once + +#include + +namespace zaparoo +{ +struct ParsedArguments +{ + bool crtNativePathForced = false; + bool versionRequested = false; + std::vector argv; + // Unfiltered process arguments (nullptr-terminated). Restart execvp uses + // these because argv has frontend-only options stripped before Qt. + std::vector originalArgv; +}; + +ParsedArguments parseArguments(int argc, char* argv[]); +} // namespace zaparoo diff --git a/src/app/main.cpp b/src/app/main.cpp index 28cf9060..ed1c93f6 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -7,6 +7,7 @@ // Qt's CMake (qt_import_qml_plugins) can emit the correct link flags. #include "custom_image_provider.h" +#include "frontend_arguments.h" #include "media_image_provider.h" #include "native_video_writer.h" #include "tinted_svg_image_provider.h" @@ -28,7 +29,6 @@ #include #include #include -#include #include #include #include @@ -36,7 +36,6 @@ #include #include #include -#include // Default QPixmapCache cap is 10 MiB. With ~100 system SVGs rasterized at // 256 px sourceSize the working set straddles that limit, so navigating @@ -47,6 +46,10 @@ // pixmap decode on the UI thread is the visible "pop in" the user // flagged. constexpr int kPixmapCacheLimitKiB = 50 * 1024; +// Native progressive text keeps hard monochrome edges below 720p. At 720p +// and above, grayscale antialiasing has enough source pixels to improve curves +// without reading as a doubled blur on integer-scaled output. +constexpr uint32_t kAntialiasedTextMinHeight = 720; extern "C" int zaparoo_rust_init(bool crtNativePathForced); extern "C" void zaparoo_rust_post_qt_start(); @@ -107,48 +110,6 @@ static void qtMessageHandler(QtMsgType type, const QMessageLogContext& /*ctx*/, zaparoo_log_qt(static_cast(type), utf8.constData(), static_cast(utf8.size())); } -struct ParsedArguments -{ - bool crtNativePathForced = false; - std::vector argv; - // Unfiltered process arguments (nullptr-terminated). The restart - // execvp must use these, not `argv`: `argv` has `--crt` stripped - // for Qt, and restarting with the filtered vector would silently - // drop the native CRT path on any restart-applied setting change. - std::vector originalArgv; -}; - -static ParsedArguments extractCrtArgument(int argc, char* argv[]) -{ - ParsedArguments parsed; - parsed.argv.reserve(static_cast(argc)); - std::copy_n(argv, argc, std::back_inserter(parsed.argv)); - parsed.originalArgv = parsed.argv; - parsed.originalArgv.push_back(nullptr); - - std::vector filtered; - filtered.reserve(parsed.argv.size()); - if (!parsed.argv.empty()) - { - filtered.push_back(parsed.argv.front()); - } - - for (size_t i = 1; i < parsed.argv.size(); ++i) - { - char* arg = parsed.argv.at(i); - if (std::strcmp(arg, "--crt") == 0) - { - parsed.crtNativePathForced = true; - continue; - } - filtered.push_back(arg); - } - - parsed.argv = std::move(filtered); - parsed.argv.push_back(nullptr); - return parsed; -} - static bool envFlagEnabled(const char* name) { const QByteArray value = qgetenv(name).trimmed().toLower(); @@ -171,7 +132,15 @@ static void startupTrace(const char* stage) int main(int argc, char* argv[]) // NOLINT { - ParsedArguments parsedArgs = extractCrtArgument(argc, argv); + zaparoo::ParsedArguments parsedArgs = zaparoo::parseArguments(argc, argv); + // Keep version discovery usable over SSH and in packaging checks: this + // must return before Rust, Qt, Core, or framebuffer initialization. + if (parsedArgs.versionRequested) + { + std::printf("Zaparoo Frontend %s\n", ZAPAROO_VERSION); + return EXIT_SUCCESS; + } + const bool crtPreviewResolutionForced = !qEnvironmentVariableIsEmpty("ZAPAROO_CRT_PREVIEW_RESOLUTION"); const bool crtNativePathForced = parsedArgs.crtNativePathForced || crtPreviewResolutionForced; @@ -351,12 +320,11 @@ int main(int argc, char* argv[]) // NOLINT startupTrace("cpp:font registration complete"); bool useUnsmoothedText = crtNativePathEnabled; #ifdef ZAPAROO_EMBEDDED_BUILD - // MiSTer's progressive framebuffer is now either 1280x720 or 960x540. - // On a 1080p output the latter is presented at an exact 2x scale, which - // doubles Noto Sans's grayscale antialias fringe and makes otherwise - // aligned text look soft. Rasterize monochrome, fully hinted glyphs at - // source resolution so integer output scaling preserves hard edges. - useUnsmoothedText = true; + const uint32_t logicalVideoHeight = zaparoo_rust_video_height(); + // A 960x540 scene is integer-upscaled on 1080p output, so grayscale fringe + // pixels become visibly soft 2x blocks. Native 720p has enough source + // resolution for grayscale antialiasing to improve curves instead. + useUnsmoothedText = useUnsmoothedText || logicalVideoHeight < kAntialiasedTextMinHeight; #endif if (useUnsmoothedText) { @@ -365,9 +333,19 @@ int main(int argc, char* argv[]) // NOLINT defaultFont.setStyleStrategy(QFont::NoAntialias); defaultFont.setHintingPreference(QFont::PreferFullHinting); QGuiApplication::setFont(defaultFont); - qInfo(crtNativePathEnabled ? "CRT native path: using unsmoothed native text" - : "Embedded progressive path: using unsmoothed native text"); } + if (crtNativePathEnabled) + { + qInfo("CRT native path: using unsmoothed native text"); + } +#ifdef ZAPAROO_EMBEDDED_BUILD + else + { + qInfo("Embedded progressive path: using %s native text at %up", + useUnsmoothedText ? "unsmoothed" : "antialiased", + static_cast(logicalVideoHeight)); + } +#endif QQuickStyle::setStyle("Basic"); // Install the locale .qm translator before constructing the QML engine @@ -462,6 +440,7 @@ int main(int argc, char* argv[]) // NOLINT static_cast(zaparoo_rust_video_width())); initialProperties.insert(QStringLiteral("videoHeight"), static_cast(zaparoo_rust_video_height())); + initialProperties.insert(QStringLiteral("unsmoothedText"), useUnsmoothedText); engine.setInitialProperties(initialProperties); startupTrace("cpp:QML initial properties set"); diff --git a/src/ui/app/Main.qml b/src/ui/app/Main.qml index 0460c191..0f93849e 100644 --- a/src/ui/app/Main.qml +++ b/src/ui/app/Main.qml @@ -156,8 +156,8 @@ MainLayout { // _gamesDetailCoverMaxSize derives from _gamesCoverMaxSize, so this one // handler re-syncs both sizes whenever the grid shape changes. on_GamesCoverMaxSizeChanged: { - if (root.gamesScreenRequested || root.activeScreen === root.screenGames) - root._syncGamesModelLayout(); + if (root.gamesScreenRequested || root.favoritesScreenRequested || root.recentsScreenRequested) + root._syncCoverSizing(); } // Bind Sizing to the scene's logical dimensions, not the @@ -196,13 +196,15 @@ MainLayout { else if (screen === root.screenGames) { root.gamesScreenRequested = true; root._syncGamesModelLayout(); - } else if (screen === root.screenFavorites) + } else if (screen === root.screenFavorites) { root.favoritesScreenRequested = true; - else if (screen === root.screenFavoriteSystems) + root._syncCoverSizing(); + } else if (screen === root.screenFavoriteSystems) root.favoriteSystemsScreenRequested = true; - else if (screen === root.screenRecents) + else if (screen === root.screenRecents) { root.recentsScreenRequested = true; - else if (screen === root.screenSettings) + root._syncCoverSizing(); + } else if (screen === root.screenSettings) root.settingsScreenRequested = true; else if (screen === root.screenAbout) root.aboutScreenRequested = true; @@ -210,6 +212,10 @@ MainLayout { function _syncGamesModelLayout(): void { Browse.GamesModel.page_size = root._gamesPageSize; + root._syncCoverSizing(); + } + + function _syncCoverSizing(): void { Browse.GamesModel.set_cover_max_size(root._gamesCoverMaxSize); Browse.GamesModel.set_detail_cover_max_size(root._gamesDetailCoverMaxSize); } diff --git a/src/ui/app/MainLayout.qml b/src/ui/app/MainLayout.qml index 4bf74dbf..eb0dffa2 100644 --- a/src/ui/app/MainLayout.qml +++ b/src/ui/app/MainLayout.qml @@ -52,6 +52,7 @@ ApplicationWindow { // Desktop preview sets fullScreen=false via initialProperties. property bool fullScreen: true property bool crtNativePath: false + property bool unsmoothedText: false property bool debugCrtSafeAreaOverlay: false property string activeScreen: ScreenManager.activeScreen readonly property bool updateEnabled: Browse.BuildInfo.update_enabled @@ -249,6 +250,12 @@ ApplicationWindow { value: root.crtNativePath } + Binding { + target: Theme + property: "unsmoothedText" + value: root.unsmoothedText + } + Binding { target: Sizing property: "crtNativePath" diff --git a/src/ui/components/CoreStatusPill.qml b/src/ui/components/CoreStatusPill.qml index 428f2421..5920e631 100644 --- a/src/ui/components/CoreStatusPill.qml +++ b/src/ui/components/CoreStatusPill.qml @@ -19,10 +19,11 @@ import QtQuick import Zaparoo.Browse as Browse import Zaparoo.Theme -// Software-rendering safe status pill. Connection states keep the compact -// text-only treatment; active media work switches to a fixed-width progress -// pill with a tiny local spinner and clipped inverted foreground over the -// fill. Only this small item repaints while the spinner advances. +// Software-rendering safe status pill. Every state shares one responsive +// minimum width so connection and media labels keep a stable, readable HUD +// footprint. Content can grow beyond that minimum for long translations; +// active media work adds a tiny local spinner and clipped inverted foreground +// over the fill. Only this small item repaints while the spinner advances. Item { id: pill objectName: "coreStatusPill" @@ -100,15 +101,20 @@ Item { // Border colour leans on the same convention as the old connection // strip: warmer accent for error-class link states, muted otherwise. readonly property bool _isError: Browse.AppStatus.link_state === pill._linkUnreachable || Browse.AppStatus.connection_state === pill._connError - readonly property int _mediaMinimumWidth: Theme.crtNativePath ? Sizing.pctH(42) : Math.min(Math.max(Sizing.pctH(28), Sizing.pctW(18)), Sizing.pctH(30)) + // One width contract for every status. Connection and media states must + // keep the same responsive footprint instead of collapsing to the current + // glyph run. Long translated labels can still expand up to HeaderBar's + // available-width cap. + readonly property int _minimumWidth: Theme.crtNativePath ? Sizing.pctH(42) : Math.min(Math.max(Sizing.pctH(28), Sizing.pctW(18)), Sizing.pctH(30)) readonly property int _textMargin: Sizing.pctW(1.2) + readonly property int _textMeasureSlack: Sizing.stroke(2) readonly property int _spinnerSize: Math.max(Sizing.pctH(1.8), Sizing.fontSize(2.2)) readonly property int _spinnerDotSize: Math.max(Sizing.stroke(2), Sizing.px(pill._spinnerSize / 3)) readonly property int _spinnerGap: Sizing.pctW(0.8) readonly property int _labelNaturalWidth: Math.ceil(Math.max(labelMetrics.advanceWidth, labelMetrics.boundingRect.x + labelMetrics.boundingRect.width) - Math.min(0, labelMetrics.boundingRect.x)) readonly property int _spinnerReservedWidth: pill._spinnerActive ? pill._spinnerSize + pill._spinnerGap : 0 - readonly property int _naturalWidth: pill._labelNaturalWidth + 2 * pill._textMargin + pill._spinnerReservedWidth - readonly property int _desiredWidth: pill._isMediaActivity ? Math.max(pill._mediaMinimumWidth, pill._naturalWidth) : pill._naturalWidth + readonly property int _naturalWidth: pill._labelNaturalWidth + 2 * pill._textMargin + pill._spinnerReservedWidth + pill._textMeasureSlack + readonly property int _desiredWidth: Math.max(pill._minimumWidth, pill._naturalWidth) property int _spinnerFrame: 0 visible: pill._label !== "" diff --git a/src/ui/components/PagedGrid.qml b/src/ui/components/PagedGrid.qml index b217934c..b3b8d471 100644 --- a/src/ui/components/PagedGrid.qml +++ b/src/ui/components/PagedGrid.qml @@ -197,6 +197,10 @@ Item { // callers leave this false; their pending targets always resolve // immediately because totalPageCount === pageCount. property bool hasMorePages: false + // True while model owns an RPC or frame-gapped append tail. Pending-target + // navigation waits for it to clear before requesting another cursor page, + // preventing later-page rows from interleaving with current append. + property bool loadingMore: false // Pending wrap-target state. Set by Up-at-page-0, Down-past-last- // loaded, and pageBy when the destination page hasn't been fetched @@ -425,9 +429,8 @@ Item { return; } if (root.hasMorePages) { - // Keep loading; `fetch_more_*` is debounced model-side via - // `loading_more`, so a redundant emit is cheap. - root.loadMoreRequested(true); + if (!root.loadingMore) + root.loadMoreRequested(true); return; } // Dataset genuinely can't reach the target (Core revised the @@ -454,10 +457,8 @@ Item { if (targetIdx >= root.itemCount) { // Specific (page, row, col) slot not realised yet. if (root.hasMorePages) { - // Keep the chain going; `fetch_more` is debounced - // model-side via `loading_more`, so a redundant emit - // is cheap. - root.loadMoreRequested(true); + if (!root.loadingMore) + root.loadMoreRequested(true); return; } // Model says no more pages are coming. Settle on the @@ -636,6 +637,11 @@ Item { root._commitPendingTarget(); } + onLoadingMoreChanged: { + if (!root.loadingMore && root.hasPendingTarget) + root._commitPendingTarget(); + } + onItemCountChanged: { // Destroying the Repeater during suspension briefly reports zero before // itemCount rebinds to source count. That is not model shrinkage and diff --git a/src/ui/components/ScrollingCaption.qml b/src/ui/components/ScrollingCaption.qml index e496dfe0..fce934b3 100644 --- a/src/ui/components/ScrollingCaption.qml +++ b/src/ui/components/ScrollingCaption.qml @@ -41,6 +41,7 @@ Item { // Center the static block (grid tiles) vs. left-align it (list rows). property bool centerContent: false property int fontPixelSize: Sizing.fontSize(2.2) + property int fontWeight: Font.Normal property string fontFamily: Theme.fontUi property color nameColor: Theme.textLabel property color variantColor: Theme.textVariant @@ -103,6 +104,7 @@ Item { text: root.name font.family: root.fontFamily font.pixelSize: root.fontPixelSize + font.weight: root.fontWeight } TextMetrics { @@ -112,6 +114,7 @@ Item { text: root.tags font.family: root.fontFamily font.pixelSize: root.fontPixelSize + font.weight: root.fontWeight } // Steps the marquee one pixel per tick with a dwell at each end. Runs only @@ -163,6 +166,7 @@ Item { color: root.nameColor font.family: root.fontFamily font.pixelSize: root.fontPixelSize + font.weight: root.fontWeight elide: (!root._marquee && root._nameRenderW < root._nameFullW) ? Text.ElideRight : Text.ElideNone horizontalAlignment: Text.AlignLeft verticalAlignment: Text.AlignVCenter @@ -180,6 +184,7 @@ Item { color: root.variantColor font.family: root.fontFamily font.pixelSize: root.fontPixelSize + font.weight: root.fontWeight // Elide from the LEFT so the specific, most-distinguishing end of a // long token suffix (`...lightgun`, `...system-1`) stays visible. elide: (!root._marquee && root._tagsRenderW < root._tagsFullW) ? Text.ElideLeft : Text.ElideNone diff --git a/src/ui/components/Tile.qml b/src/ui/components/Tile.qml index c00a24f7..d09e176c 100644 --- a/src/ui/components/Tile.qml +++ b/src/ui/components/Tile.qml @@ -133,7 +133,12 @@ Item { readonly property int _outlineWidth: Sizing.stroke(Sizing.pctH(0.6)) readonly property int _captionHeight: Sizing.pctH(5.5) readonly property int _captionGap: Sizing.pctH(0.4) - readonly property int _captionTextSize: Sizing.fontSize(2.2) + // Noto Sans has no optical-size axis or embedded bitmap strikes. Its 12px + // Regular raster is too thin when forced to a monochrome mask at 540p, so + // use the next proportional size and Medium weight only in that mode. + readonly property bool _compactUnsmoothedCaption: Theme.unsmoothedText && !Theme.crtNativePath + readonly property int _captionTextSize: Sizing.fontSize(root._compactUnsmoothedCaption ? 2.4 : 2.2) + readonly property int _captionTextWeight: root._compactUnsmoothedCaption ? Font.Medium : Font.Normal readonly property bool _hasTopLabel: root.delegateTopLabel !== "" readonly property int _topLabelHeight: Sizing.pctH(4.2) readonly property int _topLabelGap: Sizing.pctH(0.4) @@ -610,6 +615,7 @@ Item { ScrollingCaption { id: caption + objectName: "tileCaption" x: root._captionSideInset y: parent.height - root._captionHeight width: root._captionTextMaxWidth @@ -620,6 +626,7 @@ Item { name: root.delegateName tags: root.delegateDisambiguatingTags fontPixelSize: root._captionTextSize + fontWeight: root._captionTextWeight nameColor: root._focusedSelection ? Theme.textPrimary : Theme.textLabel } } diff --git a/src/ui/screens/GamesScreen.qml b/src/ui/screens/GamesScreen.qml index 8c50f118..57b5de97 100644 --- a/src/ui/screens/GamesScreen.qml +++ b/src/ui/screens/GamesScreen.qml @@ -175,6 +175,7 @@ MediaListScreen { gridRowsOverride: games._gridRows gridTotalItemsOverride: Browse.GamesModel.total_dirs + Browse.GamesModel.total_files gridHasMorePages: Browse.GamesModel.has_next_page + gridLoadingMore: Browse.GamesModel.loading_more gridLoadMoreAction: _urgent => { // Letter jumps bulk-load to their target and held rapid scrolling uses // larger chunks. A pending ordinary page turn is urgent only because diff --git a/src/ui/screens/MediaListScreen.qml b/src/ui/screens/MediaListScreen.qml index 63055120..3518e848 100644 --- a/src/ui/screens/MediaListScreen.qml +++ b/src/ui/screens/MediaListScreen.qml @@ -142,6 +142,7 @@ Item { property string bottomStatusRightText: "" property int gridTotalItemsOverride: -1 property bool gridHasMorePages: false + property bool gridLoadingMore: false // False for cursor-based queries that cannot know their final page until // the cursor is exhausted. Hides growing denominators/scroll thumbs while // retaining current-page text and directional arrows. @@ -584,6 +585,7 @@ Item { rowsOverride: root.gridRowsOverride totalItemsOverride: root.gridTotalItemsOverride hasMorePages: root.gridHasMorePages + loadingMore: root.gridLoadingMore paginationTotalKnown: root.paginationTotalKnown tileTopLabelProvider: root.gridTileTopLabelProvider coverRequestsEnabled: root.coverRevealReady diff --git a/src/ui/theme/Theme.qml b/src/ui/theme/Theme.qml index f0629d55..6817b4ac 100644 --- a/src/ui/theme/Theme.qml +++ b/src/ui/theme/Theme.qml @@ -8,6 +8,9 @@ import QtQuick // Never hardcode colors or font families inline — use these instead. QtObject { property bool crtNativePath: false + // Effective native-text raster mode selected before QML construction. + // Progressive scenes below 720p and CRT use monochrome glyph masks. + property bool unsmoothedText: false // Backgrounds readonly property color bgDeep: "#0f0f23" diff --git a/src/ui/translations/frontend_ar.ts b/src/ui/translations/frontend_ar.ts index 3f4735f9..d0b99ffd 100644 --- a/src/ui/translations/frontend_ar.ts +++ b/src/ui/translations/frontend_ar.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected غير متصل - + Reconnecting… جارٍ إعادة الاتصال… - + Connecting… جارٍ الاتصال… - + Core error خطأ في النواة - + Paused %1/%2 - + Paused متوقف مؤقتًا - + Opt… - + Optimizing جارٍ التحسين - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried فهرسة %1/%2 - %3 - + Indexing %1/%2 فهرسة %1/%2 - + Idx %1/%2 - + Indexing… جارٍ الفهرسة… @@ -324,17 +324,17 @@ Français - Wilfried استخراج %1/%2 - %3 - + Scraping %1/%2 استخراج %1/%2 - + Scr %1/%2 - + Scraping… جارٍ الاستخراج… @@ -499,7 +499,7 @@ Français - Wilfried GamesScreen - + %1 files %1 ملفات @@ -510,7 +510,7 @@ Français - Wilfried - + %1 / %2 @@ -654,58 +654,58 @@ Français - Wilfried Main - + Launch core تشغيل النواة - - + + Change launcher - - + + Update media database تحديث قاعدة بيانات الوسائط - - + + Scrape metadata استخراج البيانات الوصفية - - + + Unhide - - + + Hide - - + + Launch game تشغيل اللعبة - + Remove from favorites إزالة من المفضلة - + Add to favorites أضف إلى المفضلة - + Write to NFC token الكتابة إلى رمز NFC @@ -714,171 +714,171 @@ Français - Wilfried رمز QR - - - + + + Default افتراضي - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites المفضلة - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry إعادة المحاولة - + Cancel إلغاء - + Loading systems… جارٍ تحميل الأنظمة… - + Loading games… جارٍ تحميل الألعاب… - + Loading game… - + Loading favorites… جارٍ تحميل المفضلة… - + Loading recently played… جارٍ تحميل آخر ما تم لعبه… - + Loading settings… - + Loading… جارٍ التحميل… @@ -886,136 +886,136 @@ Français - Wilfried MainLayout - + Writing failed فشلت الكتابة - + Put a writable card near the reader ضع بطاقة قابلة للكتابة بالقرب من القارئ - + Zaparoo Frontend - - + + Favorites المفضلة - + Recently Played تم لعبها مؤخرًا - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK حسنًا - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? هل أنت متأكد أنك تريد الخروج؟ - - - - - - - - + + + + + + + + Move تحريك - - + + Select تحديد - - - - + + + + Close إغلاق - - - + + + Cancel إلغاء - + Done تم - - - - + + + + Retry إعادة المحاولة - + I understand أفهم - + Adjust - + Save @@ -1024,41 +1024,41 @@ Français - Wilfried ابدأ - - - - - + + + + + Open فتح - + Quit إنهاء - - - - - - - - - - - - - + + + + + + + + + + + + + Back رجوع - - - - + + + + View @@ -1067,25 +1067,25 @@ Français - Wilfried صفحة - - - - + + + + Options الخيارات - + Change تغيير - + Toggle تبديل - + Scroll تمرير @@ -1098,12 +1098,12 @@ Français - Wilfried جارٍ التحميل… - + %1 entries %1 عناصر - + %1 / %2 @@ -1753,7 +1753,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_de.ts b/src/ui/translations/frontend_de.ts index 063d5109..9603f39a 100644 --- a/src/ui/translations/frontend_de.ts +++ b/src/ui/translations/frontend_de.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Getrennt - + Reconnecting… Verbindung wird wiederhergestellt… - + Connecting… Verbindung wird aufgebaut… - + Core error Core-Fehler - + Paused %1/%2 - + Paused Pausiert - + Opt… - + Optimizing Wird optimiert - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indizierung %1/%2 - + Idx %1/%2 - + Indexing… Indizierung… @@ -324,17 +324,17 @@ Français - Wilfried Scraping pausiert - + Scraping %1/%2 Metadatenabruf %1/%2 - + Scr %1/%2 - + Scraping… Metadaten werden abgerufen… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 Dateien @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,28 +642,28 @@ Français - Wilfried Main - + Launch core Core starten - - + + Change launcher - + Remove from favorites Aus Favoriten entfernen - + Add to favorites Zu Favoriten hinzufügen - + Write to NFC token Auf NFC-Token schreiben @@ -672,201 +672,201 @@ Français - Wilfried QR-Code - - + + Launch game Spiel starten - + Go to... - - - + + + View - + Random favorite - + Loading systems… Systeme werden geladen… - + Loading favorites… Favoriten werden geladen… - + Loading games… Spiele werden geladen… - - + + Update media database Mediendatenbank aktualisieren - - + + Scrape metadata Metadaten abrufen - - + + Unhide - - + + Hide - - - + + + Default Standard - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Favoriten - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Wiederholen - + Cancel Abbrechen - + Loading game… - + Loading recently played… Zuletzt gespielte werden geladen… - + Loading settings… - + Loading… Wird geladen… @@ -874,85 +874,85 @@ Français - Wilfried MainLayout - + Writing failed Schreiben fehlgeschlagen - + Put a writable card near the reader Beschreibbare Karte an den Leser halten - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK In Ordnung - + Random game - + No matching games found. - + Are you sure you want to exit? Wirklich beenden? - - + + Select Auswählen - - - - + + + + Close Schließen - - - + + + Cancel Abbrechen - + Done Fertig - + I understand Ich verstehe - + Adjust - + Save @@ -961,89 +961,89 @@ Français - Wilfried Starten - + Scroll Scrollen - - - - + + + + View - - - - - - - - + + + + + + + + Move Bewegen - + Zaparoo Frontend - - + + Favorites Favoriten - + Recently Played Zuletzt gespielt - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Öffnen - + Quit Beenden - - - - - - - - - - - - - + + + + + + + + + + + + + Back Zurück @@ -1052,28 +1052,28 @@ Français - Wilfried Seite - - - - + + + + Options Optionen - - - - + + + + Retry Wiederholen - + Change Ändern - + Toggle Umschalten @@ -1086,12 +1086,12 @@ Français - Wilfried Wird geladen… - + %1 entries %1 Einträge - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_el.ts b/src/ui/translations/frontend_el.ts index 0ffd637b..360e0329 100644 --- a/src/ui/translations/frontend_el.ts +++ b/src/ui/translations/frontend_el.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Αποσυνδεδεμένο - + Reconnecting… Επανασύνδεση… - + Connecting… Σύνδεση… - + Core error Σφάλμα Core - + Paused %1/%2 - + Paused Σε παύση - + Opt… - + Optimizing Βελτιστοποίηση - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Ευρετηρίαση %1/%2 - + Idx %1/%2 - + Indexing… Ευρετηρίαση… @@ -324,17 +324,17 @@ Français - Wilfried Scraping σε παύση - + Scraping %1/%2 Συλλογή %1/%2 - + Scr %1/%2 - + Scraping… Γίνεται συλλογή… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 αρχεία @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,28 +642,28 @@ Français - Wilfried Main - + Launch core Εκκίνηση Core - - + + Change launcher - + Remove from favorites Αφαίρεση από αγαπημένα - + Add to favorites Προσθήκη στα αγαπημένα - + Write to NFC token Εγγραφή σε NFC token @@ -672,201 +672,201 @@ Français - Wilfried Κωδικός QR - - + + Launch game Εκκίνηση παιχνιδιού - + Go to... - - - + + + View - + Random favorite - + Loading systems… Φόρτωση συστημάτων… - + Loading favorites… Φόρτωση αγαπημένων… - + Loading games… Φόρτωση παιχνιδιών… - - + + Update media database Ενημέρωση βάσης δεδομένων - - + + Scrape metadata Ανάκτηση μεταδεδομένων - - + + Unhide - - + + Hide - - - + + + Default Προεπιλογή - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Αγαπημένα - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Επανάληψη - + Cancel Ακύρωση - + Loading game… - + Loading recently played… Φόρτωση πρόσφατα παιγμένων… - + Loading settings… - + Loading… Φόρτωση… @@ -874,85 +874,85 @@ Français - Wilfried MainLayout - + Writing failed Αποτυχία εγγραφής - + Put a writable card near the reader Τοποθετήστε μια εγγράψιμη κάρτα κοντά στον αναγνώστη - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Εντάξει - + Random game - + No matching games found. - + Are you sure you want to exit? Είστε σίγουροι ότι θέλετε να βγείτε; - - + + Select Επιλογή - - - - + + + + Close Κλείσιμο - - - + + + Cancel Ακύρωση - + Done Ολοκληρώθηκε - + I understand Κατανοώ - + Adjust - + Save @@ -961,89 +961,89 @@ Français - Wilfried Έναρξη - + Scroll Κύλιση - - - - + + + + View - - - - - - - - + + + + + + + + Move Μετακίνηση - + Zaparoo Frontend - - + + Favorites Αγαπημένα - + Recently Played Πρόσφατα Παιγμένα - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Άνοιγμα - + Quit Έξοδος - - - - - - - - - - - - - + + + + + + + + + + + + + Back Πίσω @@ -1052,28 +1052,28 @@ Français - Wilfried Σελίδα - - - - + + + + Options Επιλογές - - - - + + + + Retry Επανάληψη - + Change Αλλαγή - + Toggle Εναλλαγή @@ -1086,12 +1086,12 @@ Français - Wilfried Φόρτωση… - + %1 entries %1 εγγραφές - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_en.ts b/src/ui/translations/frontend_en.ts index 7f1a5729..ade282ab 100644 --- a/src/ui/translations/frontend_en.ts +++ b/src/ui/translations/frontend_en.ts @@ -238,83 +238,83 @@ Français - Wilfried CoreStatusPill - - + + Disconnected - + Reconnecting… - + Connecting… - + Core error Core error - + Paused %1/%2 - + Paused - + Opt… - + Optimizing - + Idx… - + Scr… - + Indexing %1/%2 - + Idx %1/%2 - + Indexing… - + Scraping %1/%2 - + Scr %1/%2 - + Scraping… @@ -435,7 +435,7 @@ Français - Wilfried GamesScreen - + %1 files %1 files @@ -446,7 +446,7 @@ Français - Wilfried - + %1 / %2 @@ -598,227 +598,227 @@ Français - Wilfried Main - + Launch core - - + + Change launcher - + Remove from favorites - + Add to favorites - + Write to NFC token Write to NFC token - - + + Launch game - + Go to... - - - + + + View - + Random favorite - + Loading systems… - + Loading favorites… - + Loading games… - - + + Update media database - - + + Scrape metadata - - + + Unhide - - + + Hide - - - + + + Default Default - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Favorites - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Retry - + Cancel Cancel - + Loading game… - + Loading recently played… - + Loading settings… - + Loading… Loading… @@ -826,198 +826,198 @@ Français - Wilfried MainLayout - + Writing failed Writing failed - + Put a writable card near the reader Put a writable card near the reader - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK OK - + Random game - + No matching games found. - + Are you sure you want to exit? - - + + Select Select - - - - + + + + Close Close - - - + + + Cancel Cancel - + Done - + I understand - + Adjust - + Save - + Scroll - - - - + + + + View - - - - - - - - + + + + + + + + Move Move - + Zaparoo Frontend - - + + Favorites Favorites - + Recently Played Recently Played - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Open - + Quit Quit - - - - - - - - - - - - - + + + + + + + + + + + + + Back Back - - - - + + + + Options - - - - + + + + Retry Retry - + Change Change - + Toggle Toggle @@ -1030,12 +1030,12 @@ Français - Wilfried Loading… - + %1 entries %1 entries - + %1 / %2 @@ -1673,7 +1673,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_es.ts b/src/ui/translations/frontend_es.ts index f4654f03..25b91c78 100644 --- a/src/ui/translations/frontend_es.ts +++ b/src/ui/translations/frontend_es.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Desconectado - + Reconnecting… Reconectando… - + Connecting… Conectando… - + Core error Error del Core - + Paused %1/%2 - + Paused Pausado - + Opt… - + Optimizing Optimizando - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Extrayendo %1/%2 - %3 - + Indexing %1/%2 Indexando %1/%2 - + Idx %1/%2 - + Indexing… Indexando… @@ -324,17 +324,17 @@ Français - Wilfried Scraping en pausa - + Scraping %1/%2 Extrayendo %1/%2 - + Scr %1/%2 - + Scraping… Extrayendo… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 archivos @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,28 +642,28 @@ Français - Wilfried Main - + Launch core Lanzar core - - + + Change launcher - + Remove from favorites Eliminar de favoritos - + Add to favorites Agregar a favoritos - + Write to NFC token Escribir en token NFC @@ -672,201 +672,201 @@ Français - Wilfried Código QR - - + + Launch game Iniciar juego - + Go to... - - - + + + View - + Random favorite - + Loading systems… Cargando sistemas… - + Loading favorites… Cargando favoritos… - + Loading games… Cargando juegos… - - + + Update media database Actualizar base de datos de medios - - + + Scrape metadata Extraer metadatos - - + + Unhide - - + + Hide - - - + + + Default Por defecto - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Favoritos - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Reintentar - + Cancel Cancelar - + Loading game… - + Loading recently played… Cargando jugados recientemente… - + Loading settings… - + Loading… Cargando… @@ -874,85 +874,85 @@ Français - Wilfried MainLayout - + Writing failed Error de escritura - + Put a writable card near the reader Pon una tarjeta escribible junto al lector - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Aceptar - + Random game - + No matching games found. - + Are you sure you want to exit? ¿Seguro que quieres salir? - - + + Select Seleccionar - - - - + + + + Close Cerrar - - - + + + Cancel Cancelar - + Done Hecho - + I understand Entendido - + Adjust - + Save @@ -961,89 +961,89 @@ Français - Wilfried Iniciar - + Scroll Desplazar - - - - + + + + View - - - - - - - - + + + + + + + + Move Mover - + Zaparoo Frontend - - + + Favorites Favoritos - + Recently Played - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Abrir - + Quit Salir - - - - - - - - - - - - - + + + + + + + + + + + + + Back Atrás @@ -1052,28 +1052,28 @@ Français - Wilfried Página - - - - + + + + Options Opciones - - - - + + + + Retry Reintentar - + Change Cambiar - + Toggle Alternar @@ -1086,12 +1086,12 @@ Français - Wilfried Cargando… - + %1 entries %1 entradas - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_eu.ts b/src/ui/translations/frontend_eu.ts index 53068d23..9bb7f013 100644 --- a/src/ui/translations/frontend_eu.ts +++ b/src/ui/translations/frontend_eu.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Deskonektatuta - + Reconnecting… Berkonektatzen... - + Connecting… Konektatzen... - + Core error Nukleo errorea - + Paused %1/%2 - + Paused Geldituta - + Opt… - + Optimizing Optimizatzen - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scrapeatzen %1/%2 - %3 - + Indexing %1/%2 Indexatzen %1/%2 - + Idx %1/%2 - + Indexing… Indexatzen... @@ -324,17 +324,17 @@ Français - Wilfried Scrapeatzea pausatuta - + Scraping %1/%2 Scrapeatzen %1/%2 - + Scr %1/%2 - + Scraping… Scrapeatzen... @@ -491,7 +491,7 @@ Français - Wilfried GamesScreen - + %1 files %1 fitxategi @@ -502,7 +502,7 @@ Français - Wilfried - + %1 / %2 %1 / %2 @@ -654,28 +654,28 @@ Français - Wilfried Main - + Launch core Exekutatu nukleoa - - + + Change launcher Aldatu abiarazlea - + Remove from favorites Kendu gogokoetatik - + Add to favorites Gehitu gogokoetan - + Write to NFC token Idatzi NFC token-a @@ -684,201 +684,201 @@ Français - Wilfried QR kodea - - + + Launch game Abiarazi jokua - + Go to... - - - + + + View - + Random favorite - + Loading systems… Sistemak kargatzen - + Loading favorites… Gogokoak kargatzen - + Loading games… Jokoak kargatzen - - + + Update media database Eguneratu multimedia datu-basea - - + + Scrape metadata Metadatuak scrapeatu - - + + Unhide - - + + Hide - - - + + + Default Lehenetsia - + Current: %1 Unekoa: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Gogokoak - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher Abiarazlea gordetzen - + Saving… Gordetzen... - + Launcher update failed Abiarazle egukeraketak huts egin du - + Error: %1 Errorea: %1 - + Retry Berriro saiatu - + Cancel Utzi - + Loading game… Jokua kargatzen... - + Loading recently played… Duela gutxi jokatutakoak kargatzen... - + Loading settings… - + Loading… Kargatzen… @@ -886,85 +886,85 @@ Français - Wilfried MainLayout - + Writing failed Idazketak huts egin du - + Put a writable card near the reader Jarri idatzi daitekeen txartel bat irakurlearen ondoan - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Ados - + Random game - + No matching games found. - + Are you sure you want to exit? Ziur zaude irten nahi duzula? - - + + Select Aukeratu - - - - + + + + Close Itxi - - - + + + Cancel Utzi - + Done Eginda - + I understand Ulertzen dut - + Adjust - + Save @@ -973,89 +973,89 @@ Français - Wilfried Hasi - + Scroll Scroll-a egin - - - - + + + + View - - - - - - - - + + + + + + + + Move Mugitu - + Zaparoo Frontend Zaparoo Frontend - - + + Favorites Gogokoak - + Recently Played Duela gutxi jokatuak - + Quit and restart Zaparoo Frontend? Itxi eta Zaparoo Frontend berabiarazi - + In order to apply this setting we need to restart the frontend. Ezarpenak indarrean jartzeko frontend-a berrabiarazi behar dugu - + Quit Zaparoo Frontend? Itxi Zaparoo Frontend? - - - - - + + + + + Open Ireki - + Quit Irten - - - - - - - - - - - - - + + + + + + + + + + + + + Back Atzera @@ -1064,28 +1064,28 @@ Français - Wilfried Orria - - - - + + + + Options Aukerak - - - - + + + + Retry Berriro saiatu - + Change Aldatu - + Toggle Txandakatu @@ -1098,12 +1098,12 @@ Français - Wilfried Kargatzen… - + %1 entries %1 sarrera - + %1 / %2 %1 / %2 @@ -1753,7 +1753,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_fr.ts b/src/ui/translations/frontend_fr.ts index dbf9d6d0..60d4786b 100644 --- a/src/ui/translations/frontend_fr.ts +++ b/src/ui/translations/frontend_fr.ts @@ -241,83 +241,83 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Déconnecté - + Reconnecting… Reconnexion… - + Connecting… Connexion… - + Core error Erreur Core - + Paused %1/%2 En pause %1/%2 - + Paused En pause - + Opt… Opt… - + Optimizing Optimisation - + Idx… Idx… - + Scr… Scr… - + Indexing %1/%2 Indexation %1/%2 - + Idx %1/%2 Idx %1/%2 - + Indexing… Indexation… - + Scraping %1/%2 Scraping %1/%2 - + Scr %1/%2 Scr %1/%2 - + Scraping… Scraping… @@ -470,7 +470,7 @@ Français - Wilfried GamesScreen - + %1 files %1 fichiers @@ -481,7 +481,7 @@ Français - Wilfried - + %1 / %2 %1 / %2 @@ -633,28 +633,28 @@ Français - Wilfried Main - + Launch core Lancer le core - - + + Change launcher Modifier le lanceur - + Remove from favorites Retirer des favoris - + Add to favorites Ajouter aux favoris - + Write to NFC token Écrire sur un badge NFC @@ -663,201 +663,201 @@ Français - Wilfried QR code - - + + Launch game Lancer le jeu - + Go to... Aller à... - - - + + + View Afficher - + Loading systems… Chargement des systèmes… - + Loading favorites… Chargement des favoris… - + Loading games… Chargement des jeux… - - + + Update media database Mettre à jour la base de données média - - + + Scrape metadata Scraper les métadonnées - - + + Unhide Afficher - - + + Hide Masquer - - - + + + Default Par défaut - + Current: %1 Actuel : %1 - - - - - + + + + + Random game - + Write with QR code - + Show: %1 - - + + Favorites Favoris - - + + All - + Show - + Sort: %1 - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - - + + A-Z - + Sort - + Saving launcher Enregistrement du lanceur - + Saving… Enregistrement… - + Launcher update failed Échec de la mise à jour du lanceur - + Error: %1 Erreur : %1 - + Retry Réessayer - + Cancel Annuler - + Loading game… Chargement du jeu… - + Loading recently played… Chargement des jeux récents… - + Loading settings… Chargement des réglages… - + Loading… Chargement… @@ -865,85 +865,85 @@ Français - Wilfried MainLayout - + Writing failed Échec de l'écriture - + Put a writable card near the reader Placez une carte inscriptible près du lecteur - + Update Zaparoo Core Mettre à jour Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. Ce frontend nécessite Zaparoo Core %1 ou plus récent. Vous utilisez la version %2. Certaines fonctionnalités peuvent ne pas fonctionner tant que la mise à jour n'est pas effectuée. - - - + + + OK OK - + Random game - + No matching games found. - + Are you sure you want to exit? Êtes-vous sûr de vouloir quitter ? - - + + Select Sélectionner - - - - + + + + Close Fermer - - - + + + Cancel Annuler - + Done Terminé - + I understand J'ai compris - + Adjust Ajuster - + Save Enregistrer @@ -952,115 +952,115 @@ Français - Wilfried Démarrer - + Scroll Défiler - - - - + + + + View Afficher - - - - - - - - + + + + + + + + Move Déplacer - + Zaparoo Frontend Zaparoo Frontend - - + + Favorites Favoris - + Recently Played Joués récemment - + Quit and restart Zaparoo Frontend? Quitter et redémarrer Zaparoo Frontend ? - + In order to apply this setting we need to restart the frontend. Pour appliquer ce réglage, le frontend doit être redémarré. - + Quit Zaparoo Frontend? Quitter Zaparoo Frontend ? - - - - - + + + + + Open Ouvrir - + Quit Quitter - - - - - - - - - - - - - + + + + + + + + + + + + + Back Retour - - - - + + + + Options Options - - - - + + + + Retry Réessayer - + Change Modifier - + Toggle Basculer @@ -1073,12 +1073,12 @@ Français - Wilfried Chargement… - + %1 entries %1 entrées - + %1 / %2 %1 / %2 @@ -1720,7 +1720,7 @@ Français - Wilfried Tile - + Hidden Masqué diff --git a/src/ui/translations/frontend_he.ts b/src/ui/translations/frontend_he.ts index 7716a58c..950b083f 100644 --- a/src/ui/translations/frontend_he.ts +++ b/src/ui/translations/frontend_he.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected מנותק - + Reconnecting… מתחבר מחדש… - + Connecting… מתחבר… - + Core error שגיאת Core - + Paused %1/%2 - + Paused מושהה - + Opt… - + Optimizing מבצע אופטימיזציה - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried מאנדקס %1/%2 - %3 - + Indexing %1/%2 מאנדקס %1/%2 - + Idx %1/%2 - + Indexing… מאנדקס… @@ -324,17 +324,17 @@ Français - Wilfried אוסף נתונים %1/%2 - %3 - + Scraping %1/%2 אוסף נתונים %1/%2 - + Scr %1/%2 - + Scraping… אוסף נתונים… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 קבצים @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,58 +642,58 @@ Français - Wilfried Main - + Launch core הפעל את הליבה - - + + Change launcher - - + + Update media database עדכון מסד נתוני המדיה - - + + Scrape metadata איסוף מטא-נתונים - - + + Unhide - - + + Hide - - + + Launch game הפעל משחק - + Remove from favorites הסר מהמועדפים - + Add to favorites הוסף למועדפים - + Write to NFC token כתוב לטוקן NFC @@ -702,171 +702,171 @@ Français - Wilfried קוד QR - - - + + + Default ברירת מחדל - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites מועדפים - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry נסה שוב - + Cancel ביטול - + Loading systems… טוען מערכות… - + Loading games… טוען משחקים… - + Loading game… - + Loading favorites… טוען מועדפים… - + Loading recently played… טוען את הפריטים ששוחקו לאחרונה… - + Loading settings… - + Loading… טוען… @@ -874,136 +874,136 @@ Français - Wilfried MainLayout - + Writing failed הכתיבה נכשלה - + Put a writable card near the reader הניחו כרטיס הניתן לכתיבה ליד הקורא - + Zaparoo Frontend - - + + Favorites מועדפים - + Recently Played שוחקו לאחרונה - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK אישור - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? האם אתה בטוח שברצונך לצאת? - - - - - - - - + + + + + + + + Move הזזה - - + + Select בחירה - - - - + + + + Close סגור - - - + + + Cancel ביטול - + Done הושלם - - - - + + + + Retry נסה שוב - + I understand הבנתי - + Adjust - + Save @@ -1012,41 +1012,41 @@ Français - Wilfried התחל - - - - - + + + + + Open פתח - + Quit יציאה - - - - - - - - - - - - - + + + + + + + + + + + + + Back חזרה - - - - + + + + View @@ -1055,25 +1055,25 @@ Français - Wilfried עמוד - - - - + + + + Options אפשרויות - + Change שינוי - + Toggle החלף - + Scroll גלילה @@ -1086,12 +1086,12 @@ Français - Wilfried טוען… - + %1 entries %1 פריטים - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_hi.ts b/src/ui/translations/frontend_hi.ts index 4ec46c50..3eae2413 100644 --- a/src/ui/translations/frontend_hi.ts +++ b/src/ui/translations/frontend_hi.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected डिस्कनेक्टेड - + Reconnecting… फिर से कनेक्ट किया जा रहा है… - + Connecting… कनेक्ट किया जा रहा है… - + Core error कोर त्रुटि - + Paused %1/%2 - + Paused रोक दिया गया - + Opt… - + Optimizing अनुकूलित किया जा रहा है - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried इंडेक्सिंग %1/%2 - %3 - + Indexing %1/%2 इंडेक्सिंग %1/%2 - + Idx %1/%2 - + Indexing… इंडेक्सिंग… @@ -324,17 +324,17 @@ Français - Wilfried स्क्रैपिंग %1/%2 - %3 - + Scraping %1/%2 स्क्रैपिंग %1/%2 - + Scr %1/%2 - + Scraping… स्क्रैपिंग… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 फ़ाइलें @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,58 +642,58 @@ Français - Wilfried Main - + Launch core कोर चलाएँ - - + + Change launcher - - + + Update media database मीडिया डेटाबेस अपडेट करें - - + + Scrape metadata मेटाडेटा स्क्रैप करें - - + + Unhide - - + + Hide - - + + Launch game गेम चलाएँ - + Remove from favorites पसंदीदा से हटाएँ - + Add to favorites पसंदीदा में जोड़ें - + Write to NFC token NFC टोकन पर लिखें @@ -702,171 +702,171 @@ Français - Wilfried QR कोड - - - + + + Default डिफ़ॉल्ट - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites पसंदीदा - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry फिर से प्रयास करें - + Cancel रद्द करें - + Loading systems… सिस्टम लोड हो रहे हैं… - + Loading games… गेम लोड हो रहे हैं… - + Loading game… - + Loading favorites… पसंदीदा लोड हो रहे हैं… - + Loading recently played… हाल ही में खेले गए लोड हो रहे हैं… - + Loading settings… - + Loading… लोड हो रहा है… @@ -874,136 +874,136 @@ Français - Wilfried MainLayout - + Writing failed लिखना विफल हुआ - + Put a writable card near the reader रीडर के पास लिखने योग्य कार्ड रखें - + Zaparoo Frontend - - + + Favorites पसंदीदा - + Recently Played हाल ही में खेले गए - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK ठीक है - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? क्या आप वाकई बाहर निकलना चाहते हैं? - - - - - - - - + + + + + + + + Move स्थानांतरित करें - - + + Select चुनें - - - - + + + + Close बंद करें - - - + + + Cancel रद्द करें - + Done पूरा - - - - + + + + Retry फिर से प्रयास करें - + I understand मैं समझ गया - + Adjust - + Save @@ -1012,41 +1012,41 @@ Français - Wilfried शुरू करें - - - - - + + + + + Open खोलें - + Quit बंद करें - - - - - - - - - - - - - + + + + + + + + + + + + + Back वापस - - - - + + + + View @@ -1055,25 +1055,25 @@ Français - Wilfried पृष्ठ - - - - + + + + Options विकल्प - + Change बदलें - + Toggle टॉगल - + Scroll स्क्रॉल @@ -1086,12 +1086,12 @@ Français - Wilfried लोड हो रहा है… - + %1 entries %1 प्रविष्टियाँ - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_it.ts b/src/ui/translations/frontend_it.ts index 0c0ece02..77b10007 100644 --- a/src/ui/translations/frontend_it.ts +++ b/src/ui/translations/frontend_it.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Disconnesso - + Reconnecting… Riconnessione… - + Connecting… Connessione… - + Core error Errore del Core - + Paused %1/%2 - + Paused In pausa - + Opt… - + Optimizing Ottimizzazione - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Recupero %1/%2 - %3 - + Indexing %1/%2 Indicizzazione %1/%2 - + Idx %1/%2 - + Indexing… Indicizzazione… @@ -324,17 +324,17 @@ Français - Wilfried Recupero in pausa - + Scraping %1/%2 Recupero %1/%2 - + Scr %1/%2 - + Scraping… Recupero metadati… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 file @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,28 +642,28 @@ Français - Wilfried Main - + Launch core Avvia core - - + + Change launcher - + Remove from favorites Rimuovi dai preferiti - + Add to favorites Aggiungi ai preferiti - + Write to NFC token Scrivi sul token NFC @@ -672,201 +672,201 @@ Français - Wilfried Codice QR - - + + Launch game Avvia gioco - + Go to... - - - + + + View - + Random favorite - + Loading systems… Caricamento sistemi… - + Loading favorites… Caricamento preferiti… - + Loading games… Caricamento giochi… - - + + Update media database Aggiorna database multimediale - - + + Scrape metadata Recupera metadati - - + + Unhide - - + + Hide - - - + + + Default Predefinito - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Preferiti - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Riprova - + Cancel Annulla - + Loading game… - + Loading recently played… Caricamento recenti… - + Loading settings… - + Loading… Caricamento… @@ -874,85 +874,85 @@ Français - Wilfried MainLayout - + Writing failed Scrittura non riuscita - + Put a writable card near the reader Avvicina una scheda scrivibile al lettore - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Va bene - + Random game - + No matching games found. - + Are you sure you want to exit? Sei sicuro di voler uscire? - - + + Select Seleziona - - - - + + + + Close Chiudi - - - + + + Cancel Annulla - + Done Fatto - + I understand Ho capito - + Adjust - + Save @@ -961,89 +961,89 @@ Français - Wilfried Avvia - + Scroll Scorri - - - - + + + + View - - - - - - - - + + + + + + + + Move Muovi - + Zaparoo Frontend - - + + Favorites Preferiti - + Recently Played Giocati di recente - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Apri - + Quit Esci - - - - - - - - - - - - - + + + + + + + + + + + + + Back Indietro @@ -1052,28 +1052,28 @@ Français - Wilfried Pagina - - - - + + + + Options Opzioni - - - - + + + + Retry Riprova - + Change Cambia - + Toggle Alterna @@ -1086,12 +1086,12 @@ Français - Wilfried Caricamento… - + %1 entries %1 elementi - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ja.ts b/src/ui/translations/frontend_ja.ts index a5468a0a..574be3df 100644 --- a/src/ui/translations/frontend_ja.ts +++ b/src/ui/translations/frontend_ja.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 切断済み - + Reconnecting… 再接続中… - + Connecting… 接続中… - + Core error Core エラー - + Paused %1/%2 - + Paused 一時停止中 - + Opt… - + Optimizing 最適化中 - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried スクレイピング中 %1/%2 - %3 - + Indexing %1/%2 インデックス作成中 %1/%2 - + Idx %1/%2 - + Indexing… インデックス作成中… @@ -324,17 +324,17 @@ Français - Wilfried スクレイピングを一時停止 - + Scraping %1/%2 スクレイピング中 %1/%2 - + Scr %1/%2 - + Scraping… スクレイピング中… @@ -484,7 +484,7 @@ Français - Wilfried GamesScreen - + %1 files %1 ファイル @@ -495,7 +495,7 @@ Français - Wilfried - + %1 / %2 @@ -639,28 +639,28 @@ Français - Wilfried Main - + Launch core コアを起動 - - + + Change launcher - + Remove from favorites お気に入りから削除 - + Add to favorites お気に入りに追加 - + Write to NFC token NFC トークンに書き込む @@ -669,201 +669,201 @@ Français - Wilfried QR コード - - + + Launch game ゲームを起動 - + Go to... - - - + + + View - + Random favorite - + Loading systems… システムを読み込み中… - + Loading favorites… お気に入りを読み込み中… - + Loading games… ゲームを読み込み中… - - + + Update media database メディアデータベースを更新 - - + + Scrape metadata メタデータをスクレイピング - - + + Unhide - - + + Hide - - - + + + Default デフォルト - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites お気に入り - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 再試行 - + Cancel キャンセル - + Loading game… - + Loading recently played… 最近プレイしたゲームを読み込み中… - + Loading settings… - + Loading… 読み込み中… @@ -871,85 +871,85 @@ Français - Wilfried MainLayout - + Writing failed 書き込み失敗 - + Put a writable card near the reader 書き込み可能なカードをリーダーに近づけてください - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 決定 - + Random game - + No matching games found. - + Are you sure you want to exit? 本当に終了しますか? - - + + Select 選択 - - - - + + + + Close 閉じる - - - + + + Cancel キャンセル - + Done 完了 - + I understand 了解しました - + Adjust - + Save @@ -958,89 +958,89 @@ Français - Wilfried 開始 - + Scroll スクロール - - - - + + + + View - - - - - - - - + + + + + + + + Move 移動 - + Zaparoo Frontend - - + + Favorites お気に入り - + Recently Played 最近プレイしたゲーム - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open 開く - + Quit 終了 - - - - - - - - - - - - - + + + + + + + + + + + + + Back 戻る @@ -1049,28 +1049,28 @@ Français - Wilfried ページ - - - - + + + + Options オプション - - - - + + + + Retry 再試行 - + Change 変更 - + Toggle 切り替え @@ -1083,12 +1083,12 @@ Français - Wilfried 読み込み中… - + %1 entries %1 件 - + %1 / %2 @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ko.ts b/src/ui/translations/frontend_ko.ts index 5a762506..6f0a7b17 100644 --- a/src/ui/translations/frontend_ko.ts +++ b/src/ui/translations/frontend_ko.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 연결 끊김 - + Reconnecting… 재연결 중… - + Connecting… 연결 중… - + Core error 코어 오류 - + Paused %1/%2 - + Paused 일시중지됨 - + Opt… - + Optimizing 최적화 중 - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried 인덱싱 %1/%2 - %3 - + Indexing %1/%2 인덱싱 %1/%2 - + Idx %1/%2 - + Indexing… 인덱싱 중… @@ -324,17 +324,17 @@ Français - Wilfried 메타데이터 수집 %1/%2 - %3 - + Scraping %1/%2 메타데이터 수집 %1/%2 - + Scr %1/%2 - + Scraping… 메타데이터 수집 중… @@ -484,7 +484,7 @@ Français - Wilfried GamesScreen - + %1 files 파일 %1개 @@ -495,7 +495,7 @@ Français - Wilfried - + %1 / %2 @@ -639,58 +639,58 @@ Français - Wilfried Main - + Launch core 코어 실행 - - + + Change launcher - - + + Update media database 미디어 데이터베이스 업데이트 - - + + Scrape metadata 메타데이터 수집 - - + + Unhide - - + + Hide - - + + Launch game 게임 실행 - + Remove from favorites 즐겨찾기에서 제거 - + Add to favorites 즐겨찾기에 추가 - + Write to NFC token NFC 토큰에 쓰기 @@ -699,171 +699,171 @@ Français - Wilfried QR 코드 - - - + + + Default 기본값 - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites 즐겨찾기 - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 다시 시도 - + Cancel 취소 - + Loading systems… 시스템 불러오는 중… - + Loading games… 게임 불러오는 중… - + Loading game… - + Loading favorites… 즐겨찾기 불러오는 중… - + Loading recently played… 최근 플레이 불러오는 중… - + Loading settings… - + Loading… 불러오는 중… @@ -871,136 +871,136 @@ Français - Wilfried MainLayout - + Writing failed 쓰기 실패 - + Put a writable card near the reader 기록 가능한 카드를 리더 근처에 놓으세요 - + Zaparoo Frontend - - + + Favorites 즐겨찾기 - + Recently Played 최근 플레이 - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 확인 - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? 정말 종료하시겠습니까? - - - - - - - - + + + + + + + + Move 이동 - - + + Select 선택 - - - - + + + + Close 닫기 - - - + + + Cancel 취소 - + Done 완료 - - - - + + + + Retry 다시 시도 - + I understand 이해했습니다 - + Adjust - + Save @@ -1009,41 +1009,41 @@ Français - Wilfried 시작 - - - - - + + + + + Open 열기 - + Quit 종료 - - - - - - - - - - - - - + + + + + + + + + + + + + Back 뒤로 - - - - + + + + View @@ -1052,25 +1052,25 @@ Français - Wilfried 페이지 - - - - + + + + Options 옵션 - + Change 변경 - + Toggle 전환 - + Scroll 스크롤 @@ -1083,12 +1083,12 @@ Français - Wilfried 불러오는 중… - + %1 entries 항목 %1개 - + %1 / %2 @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_nl.ts b/src/ui/translations/frontend_nl.ts index ac761df9..b0fa9f2c 100644 --- a/src/ui/translations/frontend_nl.ts +++ b/src/ui/translations/frontend_nl.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Verbroken - + Reconnecting… Opnieuw verbinden… - + Connecting… Verbinden… - + Core error Core-fout - + Paused %1/%2 - + Paused Gepauzeerd - + Opt… - + Optimizing Optimaliseren - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexering %1/%2 - + Idx %1/%2 - + Indexing… Indexering… @@ -324,17 +324,17 @@ Français - Wilfried Scraping gepauzeerd - + Scraping %1/%2 Scrapen %1/%2 - + Scr %1/%2 - + Scraping… Bezig met scrapen… @@ -487,7 +487,7 @@ Français - Wilfried GamesScreen - + %1 files %1 bestanden @@ -498,7 +498,7 @@ Français - Wilfried - + %1 / %2 @@ -642,28 +642,28 @@ Français - Wilfried Main - + Launch core Core starten - - + + Change launcher - + Remove from favorites Uit favorieten verwijderen - + Add to favorites Aan favorieten toevoegen - + Write to NFC token Naar NFC-token schrijven @@ -672,201 +672,201 @@ Français - Wilfried QR-code - - + + Launch game Game starten - + Go to... - - - + + + View - + Random favorite - + Loading systems… Systemen laden… - + Loading favorites… Favorieten laden… - + Loading games… Games laden… - - + + Update media database Mediadatabase bijwerken - - + + Scrape metadata Metadata ophalen - - + + Unhide - - + + Hide - - - + + + Default Standaard - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Favorieten - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Opnieuw proberen - + Cancel Annuleren - + Loading game… - + Loading recently played… Recent gespeeld laden… - + Loading settings… - + Loading… Laden… @@ -874,85 +874,85 @@ Français - Wilfried MainLayout - + Writing failed Schrijven mislukt - + Put a writable card near the reader Houd een beschrijfbare kaart bij de lezer - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Akkoord - + Random game - + No matching games found. - + Are you sure you want to exit? Weet u zeker dat u wilt afsluiten? - - + + Select Selecteren - - - - + + + + Close Sluiten - - - + + + Cancel Annuleren - + Done Klaar - + I understand Ik begrijp het - + Adjust - + Save @@ -961,89 +961,89 @@ Français - Wilfried Starten - + Scroll Scrollen - - - - + + + + View - - - - - - - - + + + + + + + + Move Bewegen - + Zaparoo Frontend - - + + Favorites Favorieten - + Recently Played Recent gespeeld - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Openen - + Quit Afsluiten - - - - - - - - - - - - - + + + + + + + + + + + + + Back Terug @@ -1052,28 +1052,28 @@ Français - Wilfried Pagina - - - - + + + + Options Opties - - - - + + + + Retry Opnieuw proberen - + Change Wijzigen - + Toggle Schakelen @@ -1086,12 +1086,12 @@ Français - Wilfried Laden… - + %1 entries %1 items - + %1 / %2 @@ -1741,7 +1741,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_ro.ts b/src/ui/translations/frontend_ro.ts index db8763f9..3395d43c 100644 --- a/src/ui/translations/frontend_ro.ts +++ b/src/ui/translations/frontend_ro.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Deconectat - + Reconnecting… Reconectare… - + Connecting… Conectare… - + Core error Eroare Core - + Paused %1/%2 - + Paused În pauză - + Opt… - + Optimizing Optimizare - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexare %1/%2 - + Idx %1/%2 - + Indexing… Indexare… @@ -324,17 +324,17 @@ Français - Wilfried Scraping în pauză - + Scraping %1/%2 Preluare %1/%2 - + Scr %1/%2 - + Scraping… Se preiau datele… @@ -490,7 +490,7 @@ Français - Wilfried GamesScreen - + %1 files %1 fișiere @@ -501,7 +501,7 @@ Français - Wilfried - + %1 / %2 @@ -645,28 +645,28 @@ Français - Wilfried Main - + Launch core Lansare core - - + + Change launcher - + Remove from favorites Elimină din favorite - + Add to favorites Adaugă la favorite - + Write to NFC token Scriere pe token NFC @@ -675,201 +675,201 @@ Français - Wilfried Cod QR - - + + Launch game Lansare joc - + Go to... - - - + + + View - + Random favorite - + Loading systems… Se încarcă sistemele… - + Loading favorites… Se încarcă favoritele… - + Loading games… Se încarcă jocurile… - - + + Update media database Actualizare bază de date media - - + + Scrape metadata Extragere metadate - - + + Unhide - - + + Hide - - - + + + Default Implicit - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Favorite - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Reîncercare - + Cancel Anulare - + Loading game… - + Loading recently played… Se încarcă recent jucatele… - + Loading settings… - + Loading… Se încarcă… @@ -877,85 +877,85 @@ Français - Wilfried MainLayout - + Writing failed Scriere eșuată - + Put a writable card near the reader Apropiați un card inscriptibil de cititor - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK În regulă - + Random game - + No matching games found. - + Are you sure you want to exit? Sigur doriți să ieșiți? - - + + Select Selectare - - - - + + + + Close Închidere - - - + + + Cancel Anulare - + Done Gata - + I understand Am înțeles - + Adjust - + Save @@ -964,89 +964,89 @@ Français - Wilfried Pornire - + Scroll Derulare - - - - + + + + View - - - - - - - - + + + + + + + + Move Mutare - + Zaparoo Frontend - - + + Favorites Favorite - + Recently Played Recent Jucate - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Deschidere - + Quit Ieșire - - - - - - - - - - - - - + + + + + + + + + + + + + Back Înapoi @@ -1055,28 +1055,28 @@ Français - Wilfried Pagină - - - - + + + + Options Opțiuni - - - - + + + + Retry Reîncercare - + Change Schimbare - + Toggle Comutare @@ -1089,12 +1089,12 @@ Français - Wilfried Se încarcă… - + %1 entries %1 intrări - + %1 / %2 @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_sk.ts b/src/ui/translations/frontend_sk.ts index f87b349f..68fa9997 100644 --- a/src/ui/translations/frontend_sk.ts +++ b/src/ui/translations/frontend_sk.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Odpojené - + Reconnecting… Opätovné pripojenie… - + Connecting… Pripájanie… - + Core error Chyba Core - + Paused %1/%2 - + Paused Pozastavené - + Opt… - + Optimizing Optimalizácia - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Scraping %1/%2 – %3 - + Indexing %1/%2 Indexovanie %1/%2 - + Idx %1/%2 - + Indexing… Indexovanie… @@ -324,17 +324,17 @@ Français - Wilfried Scraping pozastavený - + Scraping %1/%2 Sťahovanie %1/%2 - + Scr %1/%2 - + Scraping… Prebieha sťahovanie… @@ -490,7 +490,7 @@ Français - Wilfried GamesScreen - + %1 files %1 súborov @@ -501,7 +501,7 @@ Français - Wilfried - + %1 / %2 @@ -645,28 +645,28 @@ Français - Wilfried Main - + Launch core Spustiť core - - + + Change launcher - + Remove from favorites Odstrániť z obľúbených - + Add to favorites Pridať do obľúbených - + Write to NFC token Zapísať na NFC token @@ -675,201 +675,201 @@ Français - Wilfried QR kód - - + + Launch game Spustiť hru - + Go to... - - - + + + View - + Random favorite - + Loading systems… Načítanie systémov… - + Loading favorites… Načítanie obľúbených… - + Loading games… Načítanie hier… - - + + Update media database Aktualizovať databázu médií - - + + Scrape metadata Získať metadáta - - + + Unhide - - + + Hide - - - + + + Default Predvolené - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Obľúbené - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Skúsiť znova - + Cancel Zrušiť - + Loading game… - + Loading recently played… Načítanie nedávno hraných… - + Loading settings… - + Loading… Načítanie… @@ -877,85 +877,85 @@ Français - Wilfried MainLayout - + Writing failed Zápis zlyhal - + Put a writable card near the reader Priložte zapisovateľnú kartu k čítačke - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK V poriadku - + Random game - + No matching games found. - + Are you sure you want to exit? Naozaj chcete ukončiť? - - + + Select Vybrať - - - - + + + + Close Zavrieť - - - + + + Cancel Zrušiť - + Done Hotovo - + I understand Rozumiem - + Adjust - + Save @@ -964,89 +964,89 @@ Français - Wilfried Spustiť - + Scroll Posúvať - - - - + + + + View - - - - - - - - + + + + + + + + Move Presunúť - + Zaparoo Frontend - - + + Favorites Obľúbené - + Recently Played Nedávno hrané - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Otvoriť - + Quit Ukončiť - - - - - - - - - - - - - + + + + + + + + + + + + + Back Späť @@ -1055,28 +1055,28 @@ Français - Wilfried Stránka - - - - + + + + Options Možnosti - - - - + + + + Retry Skúsiť znova - + Change Zmeniť - + Toggle Prepínať @@ -1089,12 +1089,12 @@ Français - Wilfried Načítanie… - + %1 entries %1 položiek - + %1 / %2 @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_uk.ts b/src/ui/translations/frontend_uk.ts index f740b1d1..86f38c66 100644 --- a/src/ui/translations/frontend_uk.ts +++ b/src/ui/translations/frontend_uk.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected Відключено - + Reconnecting… Повторне підключення… - + Connecting… Підключення… - + Core error Помилка Core - + Paused %1/%2 - + Paused Призупинено - + Opt… - + Optimizing Оптимізація - + Idx… - + Scr… @@ -305,17 +305,17 @@ Français - Wilfried Скрейпінг %1/%2 – %3 - + Indexing %1/%2 Індексування %1/%2 - + Idx %1/%2 - + Indexing… Індексування… @@ -324,17 +324,17 @@ Français - Wilfried Скрейпінг призупинено - + Scraping %1/%2 Скрейпінг %1/%2 - + Scr %1/%2 - + Scraping… Скрейпінг… @@ -490,7 +490,7 @@ Français - Wilfried GamesScreen - + %1 files %1 файлів @@ -501,7 +501,7 @@ Français - Wilfried - + %1 / %2 @@ -645,28 +645,28 @@ Français - Wilfried Main - + Launch core Запустити core - - + + Change launcher - + Remove from favorites Видалити з вибраного - + Add to favorites Додати до вибраного - + Write to NFC token Записати на NFC-токен @@ -675,201 +675,201 @@ Français - Wilfried QR-код - - + + Launch game Запустити гру - + Go to... - - - + + + View - + Random favorite - + Loading systems… Завантаження систем… - + Loading favorites… Завантаження вибраного… - + Loading games… Завантаження ігор… - - + + Update media database Оновити базу медіа - - + + Scrape metadata Отримати метадані - - + + Unhide - - + + Hide - - - + + + Default Типовий - + Current: %1 - - - - - + + + + + Random game - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Favorites Вибране - - + + Group by: %1 - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry Повторити - + Cancel Скасувати - + Loading game… - + Loading recently played… Завантаження нещодавніх… - + Loading settings… - + Loading… Завантаження… @@ -877,85 +877,85 @@ Français - Wilfried MainLayout - + Writing failed Помилка запису - + Put a writable card near the reader Прикладіть картку для запису до зчитувача - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK Гаразд - + Random game - + No matching games found. - + Are you sure you want to exit? Ви впевнені, що хочете вийти? - - + + Select Вибрати - - - - + + + + Close Закрити - - - + + + Cancel Скасувати - + Done Готово - + I understand Я розумію - + Adjust - + Save @@ -964,89 +964,89 @@ Français - Wilfried Почати - + Scroll Прокрутка - - - - + + + + View - - - - - - - - + + + + + + + + Move Переміщення - + Zaparoo Frontend - - + + Favorites Вибране - + Recently Played Нещодавно зіграні - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Quit Zaparoo Frontend? - - - - - + + + + + Open Відкрити - + Quit Вийти - - - - - - - - - - - - - + + + + + + + + + + + + + Back Назад @@ -1055,28 +1055,28 @@ Français - Wilfried Сторінка - - - - + + + + Options Параметри - - - - + + + + Retry Повторити - + Change Змінити - + Toggle Перемкнути @@ -1089,12 +1089,12 @@ Français - Wilfried Завантаження… - + %1 entries %1 записів - + %1 / %2 @@ -1744,7 +1744,7 @@ Français - Wilfried Tile - + Hidden diff --git a/src/ui/translations/frontend_zh_CN.ts b/src/ui/translations/frontend_zh_CN.ts index d61607a1..21c318eb 100644 --- a/src/ui/translations/frontend_zh_CN.ts +++ b/src/ui/translations/frontend_zh_CN.ts @@ -238,53 +238,53 @@ Français - Wilfried CoreStatusPill - - + + Disconnected 已断开 - + Reconnecting… 正在重新连接… - + Connecting… 正在连接… - + Core error Core 错误 - + Paused %1/%2 - + Paused 已暂停 - + Opt… - + Optimizing 正在优化 - + Idx… - + Scr… @@ -301,17 +301,17 @@ Français - Wilfried 正在索引 %1/%2 - %3 - + Indexing %1/%2 正在索引 %1/%2 - + Idx %1/%2 - + Indexing… 正在索引… @@ -324,17 +324,17 @@ Français - Wilfried 正在抓取 %1/%2 - %3 - + Scraping %1/%2 正在抓取 %1/%2 - + Scr %1/%2 - + Scraping… 正在抓取… @@ -484,7 +484,7 @@ Français - Wilfried GamesScreen - + %1 files %1 个文件 @@ -495,7 +495,7 @@ Français - Wilfried - + %1 / %2 @@ -639,58 +639,58 @@ Français - Wilfried Main - + Launch core 启动核心 - - + + Change launcher - - + + Update media database 更新媒体数据库 - - + + Scrape metadata 抓取元数据 - - + + Unhide - - + + Hide - - + + Launch game 启动游戏 - + Remove from favorites 从收藏中移除 - + Add to favorites 添加到收藏 - + Write to NFC token 写入 NFC 令牌 @@ -699,171 +699,171 @@ Français - Wilfried 二维码 - - - + + + Default 默认 - + Current: %1 - + Go to... - - - - - + + + + + Random game - - + + Favorites 收藏 - - - + + + View - + Sort: %1 - + Show: %1 - - + + A-Z - - + + All - + Sort - + Show - + Write with QR code - - + + Group by: %1 - + Random favorite - - + + System - - + + None - + Group by - + Saving launcher - + Saving… - + Launcher update failed - + Error: %1 - + Retry 重试 - + Cancel 取消 - + Loading systems… 正在加载系统… - + Loading games… 正在加载游戏… - + Loading game… - + Loading favorites… 正在加载收藏… - + Loading recently played… 正在加载最近游玩… - + Loading settings… - + Loading… 正在加载… @@ -871,136 +871,136 @@ Français - Wilfried MainLayout - + Writing failed 写入失败 - + Put a writable card near the reader 将可写卡片放在读卡器附近 - + Zaparoo Frontend - - + + Favorites 收藏 - + Recently Played 最近游玩 - + Quit and restart Zaparoo Frontend? - + In order to apply this setting we need to restart the frontend. - + Update Zaparoo Core - + This frontend needs Zaparoo Core %1 or newer. You're running %2. Some features may not work until you update. - - - + + + OK 确定 - + Random game - + No matching games found. - + Quit Zaparoo Frontend? - + Are you sure you want to exit? 确定要退出吗? - - - - - - - - + + + + + + + + Move 移动 - - + + Select 选择 - - - - + + + + Close 关闭 - - - + + + Cancel 取消 - + Done 完成 - - - - + + + + Retry 重试 - + I understand 我明白了 - + Adjust - + Save @@ -1009,41 +1009,41 @@ Français - Wilfried 开始 - - - - - + + + + + Open 打开 - + Quit 退出 - - - - - - - - - - - - - + + + + + + + + + + + + + Back 返回 - - - - + + + + View @@ -1052,25 +1052,25 @@ Français - Wilfried 页面 - - - - + + + + Options 选项 - + Change 更改 - + Toggle 切换 - + Scroll 滚动 @@ -1083,12 +1083,12 @@ Français - Wilfried 正在加载… - + %1 entries %1 个条目 - + %1 / %2 @@ -1738,7 +1738,7 @@ Français - Wilfried Tile - + Hidden diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae5b51a7..9ca5ed6b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,3 +3,33 @@ # SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 add_subdirectory(ui) + +add_executable( + frontend_arguments_test + tst_frontend_arguments.cpp + "${CMAKE_SOURCE_DIR}/src/app/frontend_arguments.cpp" +) +target_include_directories(frontend_arguments_test PRIVATE "${CMAKE_SOURCE_DIR}/src/app") +target_link_libraries(frontend_arguments_test PRIVATE Zaparoo::CompileOptions) +add_test(NAME frontend_arguments COMMAND frontend_arguments_test) + +set(_VERSION_CHECK_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/check_frontend_version.cmake") +add_test( + NAME frontend_version + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--version -P "${_VERSION_CHECK_SCRIPT}" +) +add_test( + NAME frontend_version_short + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=-v -P "${_VERSION_CHECK_SCRIPT}" +) +add_test( + NAME frontend_version_with_crt + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--crt -DARG2=--version -P + "${_VERSION_CHECK_SCRIPT}" +) diff --git a/tests/check_frontend_version.cmake b/tests/check_frontend_version.cmake new file mode 100644 index 00000000..cdc3b214 --- /dev/null +++ b/tests/check_frontend_version.cmake @@ -0,0 +1,25 @@ +# Zaparoo Frontend +# Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +# SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 + +set(_args "${ARG1}") +if(DEFINED ARG2) + list(APPEND _args "${ARG2}") +endif() + +execute_process( + COMMAND "${FRONTEND}" ${_args} + RESULT_VARIABLE _result + OUTPUT_VARIABLE _stdout + ERROR_VARIABLE _stderr +) + +if(NOT _result EQUAL 0) + message(FATAL_ERROR "frontend version command exited ${_result}: ${_stderr}") +endif() +if(NOT _stdout STREQUAL "Zaparoo Frontend ${EXPECTED}\n") + message(FATAL_ERROR "unexpected frontend version stdout: '${_stdout}'") +endif() +if(NOT _stderr STREQUAL "") + message(FATAL_ERROR "frontend version command initialized diagnostics: '${_stderr}'") +endif() diff --git a/tests/tst_frontend_arguments.cpp b/tests/tst_frontend_arguments.cpp new file mode 100644 index 00000000..1be31b66 --- /dev/null +++ b/tests/tst_frontend_arguments.cpp @@ -0,0 +1,82 @@ +// Zaparoo Frontend +// Copyright (c) 2026 Wizzo Pty Ltd and the Zaparoo Project contributors. +// SPDX-License-Identifier: LicenseRef-PolyForm-Noncommercial-1.0.0 + +#include "frontend_arguments.h" + +#include +#include +#include +#include +#include + +namespace +{ +bool check(bool condition, const char* message) +{ + if (!condition) + { + std::fprintf(stderr, "frontend argument test failed: %s\n", message); + } + return condition; +} + +bool matches(const std::vector& actual, const std::vector& expected) +{ + if (actual.size() != expected.size() + 1 || actual.back() != nullptr) + { + return false; + } + for (size_t i = 0; i < expected.size(); ++i) + { + if (std::string_view(actual.at(i)) != expected.at(i)) + { + return false; + } + } + return true; +} +} // namespace + +int main() +{ + bool passed = true; + + char program[] = "frontend"; + char* noFlags[] = {program}; + const auto plain = zaparoo::parseArguments(static_cast(std::size(noFlags)), noFlags); + passed &= check(!plain.crtNativePathForced, "no-flag launch forced CRT"); + passed &= check(!plain.versionRequested, "no-flag launch requested version"); + passed &= check(matches(plain.argv, {"frontend"}), "no-flag argv lacks null sentinel"); + passed &= check(matches(plain.originalArgv, {"frontend"}), + "no-flag original argv lacks null sentinel"); + + char crt[] = "--crt"; + char value[] = "ordinary"; + char* filteredInput[] = {program, crt, value}; + const auto filtered = + zaparoo::parseArguments(static_cast(std::size(filteredInput)), filteredInput); + passed &= check(filtered.crtNativePathForced, "--crt was not recognized"); + passed &= check(!filtered.versionRequested, "ordinary argument requested version"); + passed &= check(matches(filtered.argv, {"frontend", "ordinary"}), "--crt was not filtered"); + passed &= check(matches(filtered.originalArgv, {"frontend", "--crt", "ordinary"}), + "original argv did not preserve --crt"); + + char version[] = "--version"; + char* versionInput[] = {program, version}; + const auto versioned = + zaparoo::parseArguments(static_cast(std::size(versionInput)), versionInput); + passed &= check(versioned.versionRequested, "--version was not recognized"); + + char separator[] = "--"; + char shortVersion[] = "-v"; + char* terminatedInput[] = {program, separator, crt, shortVersion}; + const auto terminated = + zaparoo::parseArguments(static_cast(std::size(terminatedInput)), terminatedInput); + passed &= check(!terminated.crtNativePathForced, "--crt after -- was consumed"); + passed &= check(!terminated.versionRequested, "-v after -- requested version"); + passed &= check(matches(terminated.argv, {"frontend", "--", "--crt", "-v"}), + "arguments after -- were not preserved"); + + return passed ? 0 : 1; +} diff --git a/tests/ui/tst_navigation.qml b/tests/ui/tst_navigation.qml index aab90594..674e15a5 100644 --- a/tests/ui/tst_navigation.qml +++ b/tests/ui/tst_navigation.qml @@ -98,6 +98,22 @@ TestCase { Browse.GamesModel.total_files = 0; } + function test_media_screen_requests_sync_cover_size(): void { + main.gamesScreenRequested = false; + main.favoritesScreenRequested = false; + main.recentsScreenRequested = false; + + Browse.GamesModel.set_cover_max_size(0); + main._requestScreen(main.screenFavorites); + compare(Browse.GamesModel.cover_max_size, main._gamesCoverMaxSize); + verify(Browse.GamesModel.cover_max_size > 0); + + Browse.GamesModel.set_cover_max_size(0); + main._requestScreen(main.screenRecents); + compare(Browse.GamesModel.cover_max_size, main._gamesCoverMaxSize); + verify(Browse.GamesModel.cover_max_size > 0); + } + function test_first_run_index_starts_only_from_authoritative_empty_state(): void { compare(main._shouldStartFirstRunIndex(2, true, true, 0), true); compare(main._shouldStartFirstRunIndex(1, true, true, 0), false); diff --git a/tests/ui/tst_paged_grid.qml b/tests/ui/tst_paged_grid.qml index 839177ae..ed807e5f 100644 --- a/tests/ui/tst_paged_grid.qml +++ b/tests/ui/tst_paged_grid.qml @@ -141,6 +141,7 @@ TestCase { // (which skips its cleanup) doesn't poison the next case's // pageCount/totalPageCount math. grid.hasMorePages = false; + grid.loadingMore = false; grid.paginationTotalKnown = true; grid.totalItemsOverride = -1; fillModel(0); @@ -566,6 +567,28 @@ TestCase { _resetPartialLoadState(); } + function test_pending_target_waits_for_active_append_before_next_fetch(): void { + _setupPartialLoad(24, 60); + compare(grid.moveSelection(0, -1), false); + compare(grid._pendingTargetPage, 4); + + grid.loadingMore = true; + loadMoreSpy.clear(); + for (let i = 24; i < 36; i++) + model.append({ + "name": "item-" + i, + "coverKey": "", + "favorite": 0 + }); + tryCompare(grid, "itemCount", 36); + compare(loadMoreSpy.count, 0, "active append tail must suppress next cursor request"); + + grid.loadingMore = false; + tryCompare(loadMoreSpy, "count", 1); + compare(grid._pendingTargetPage, 4); + _resetPartialLoadState(); + } + function test_pending_target_commits_when_pages_load(): void { // Set up the partial-load wrap, then grow the model to cover // the target page. The itemCount-change handler must commit diff --git a/tests/ui/tst_resources.qml b/tests/ui/tst_resources.qml index d8bb5cb0..312c5d26 100644 --- a/tests/ui/tst_resources.qml +++ b/tests/ui/tst_resources.qml @@ -4,6 +4,7 @@ import QtQuick import QtTest +import Zaparoo.Browse as Browse import Zaparoo.Theme import Zaparoo.Ui @@ -146,6 +147,104 @@ TestCase { compare(pill._boundedWidth(250), 250); } + function test_status_pill_reserves_readable_width_data(): list { + return [ + { + "tag": "crt-240p", + "width": 352, + "height": 240, + "crt": true + }, + { + "tag": "480p", + "width": 640, + "height": 480, + "crt": false + }, + { + "tag": "540p", + "width": 960, + "height": 540, + "crt": false + }, + { + "tag": "720p", + "width": 1280, + "height": 720, + "crt": false + }, + { + "tag": "1080p", + "width": 1920, + "height": 1080, + "crt": false + } + ]; + } + + function test_status_pill_reserves_readable_width(data: var): void { + const originalWidth = Sizing.screenWidth; + const originalHeight = Sizing.screenHeight; + const originalSizingCrt = Sizing.crtNativePath; + const originalThemeCrt = Theme.crtNativePath; + const originalLinkState = Browse.AppStatus.link_state; + const originalConnectionState = Browse.AppStatus.connection_state; + try { + Sizing.screenWidth = data.width; + Sizing.screenHeight = data.height; + Sizing.crtNativePath = data.crt; + Theme.crtNativePath = data.crt; + Browse.AppStatus.link_state = 1; + Browse.AppStatus.connection_state = 1; + + const pill = createTemporaryObject(statusPillComponent, testCase, { + "maximumWidth": data.width + }); + verify(pill !== null); + compare(pill._connectionLabel, "Connecting…"); + compare(pill._isMediaActivity, false); + compare(pill._desiredWidth, Math.max(pill._minimumWidth, pill._naturalWidth)); + verify(pill._desiredWidth >= pill._minimumWidth, "connection and media states need the same stable status-bar footprint: desired=" + pill._desiredWidth + " minimum=" + pill._minimumWidth); + verify(pill._naturalWidth - 2 * pill._textMargin - pill._spinnerReservedWidth >= pill._labelNaturalWidth + pill._textMeasureSlack, "content width must include measured glyphs and native-rendering slack"); + } finally { + Sizing.screenWidth = originalWidth; + Sizing.screenHeight = originalHeight; + Sizing.crtNativePath = originalSizingCrt; + Theme.crtNativePath = originalThemeCrt; + Browse.AppStatus.link_state = originalLinkState; + Browse.AppStatus.connection_state = originalConnectionState; + } + } + + function test_header_status_slot_fits_minimum_at_540p(): void { + const originalWidth = Sizing.screenWidth; + const originalHeight = Sizing.screenHeight; + const originalSizingCrt = Sizing.crtNativePath; + const originalThemeCrt = Theme.crtNativePath; + try { + Sizing.screenWidth = 960; + Sizing.screenHeight = 540; + Sizing.crtNativePath = false; + Theme.crtNativePath = false; + + const header = createTemporaryObject(headerBarComponent, testCase, { + "width": 960 + }); + verify(header !== null); + const pill = findChild(header, "coreStatusPill"); + verify(pill !== null); + tryVerify(function () { + return pill.maximumWidth > 0; + }); + verify(pill.maximumWidth >= pill._minimumWidth, "header status slot must not cap the responsive minimum"); + } finally { + Sizing.screenWidth = originalWidth; + Sizing.screenHeight = originalHeight; + Sizing.crtNativePath = originalSizingCrt; + Theme.crtNativePath = originalThemeCrt; + } + } + function test_scrolling_caption_measures_painted_glyph_bounds(): void { const caption = createTemporaryObject(scrollingCaptionComponent, testCase); verify(caption !== null); @@ -159,6 +258,35 @@ TestCase { compare(caption._tagsFullW, expectedTagsWidth); } + function test_tile_caption_strengthens_only_for_progressive_unsmoothed_text(): void { + const originalHeight = Sizing.screenHeight; + const originalSizingCrt = Sizing.crtNativePath; + const originalThemeCrt = Theme.crtNativePath; + const originalUnsmoothed = Theme.unsmoothedText; + try { + Sizing.screenHeight = 540; + Sizing.crtNativePath = false; + Theme.crtNativePath = false; + Theme.unsmoothedText = true; + + const host = createTemporaryObject(missingSystemTile, testCase); + verify(host !== null); + const caption = findChild(host, "tileCaption"); + verify(caption !== null); + compare(caption.fontPixelSize, Sizing.fontSize(2.4)); + compare(caption.fontWeight, Font.Medium); + + Theme.unsmoothedText = false; + compare(caption.fontPixelSize, Sizing.fontSize(2.2)); + compare(caption.fontWeight, Font.Normal); + } finally { + Sizing.screenHeight = originalHeight; + Sizing.crtNativePath = originalSizingCrt; + Theme.crtNativePath = originalThemeCrt; + Theme.unsmoothedText = originalUnsmoothed; + } + } + function test_media_cover_uses_short_reveal_without_loading_glyph(): void { const host = createTemporaryObject(missingSystemTile, testCase, { "coverKey": "media-image/example" From 1106e738187e7f43295371f76e2378de3661da09 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 18 Aug 2026 10:28:16 +0800 Subject: [PATCH 8/9] test(media): use stable miss in batch fixture --- rust/frontend/src/media_meta_cache.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/frontend/src/media_meta_cache.rs b/rust/frontend/src/media_meta_cache.rs index d3100c4e..7bf58c86 100644 --- a/rust/frontend/src/media_meta_cache.rs +++ b/rust/frontend/src/media_meta_cache.rs @@ -683,7 +683,7 @@ mod tests { }, MediaMetaBatchItemResult { media: None, - error: Some("not found".into()), + error: Some("media not found: NES /mock/b".into()), }, ], }), From c955a09d7fc11cd982520d0ef064f96466d185d6 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 18 Aug 2026 11:02:43 +0800 Subject: [PATCH 9/9] fix: address follow-up review feedback --- rust/frontend/src/media_image_cache.rs | 57 ++++++++++++-- rust/frontend/src/models/games.rs | 4 +- rust/zaparoo-core/src/client.rs | 102 +++++++++++++++++++------ tests/CMakeLists.txt | 42 +++++----- tests/ui/tst_resources.qml | 23 +++--- 5 files changed, 168 insertions(+), 60 deletions(-) diff --git a/rust/frontend/src/media_image_cache.rs b/rust/frontend/src/media_image_cache.rs index 1ecb9398..438f43af 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -41,7 +41,7 @@ use std::time::{Duration, Instant}; use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; use base64::Engine as _; use tokio::runtime::Handle; -use tokio::sync::{broadcast, Notify}; +use tokio::sync::{broadcast, Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard, Notify}; use tracing::{debug, info, warn}; use zaparoo_core::client::ClientError; @@ -98,6 +98,33 @@ const FETCH_DRIVER_WORKERS: usize = 2; /// parameter. The frontend then stays inline for the process lifetime instead /// of doubling every cover request with a known-unsupported probe. static LOCAL_PATH_REQUESTS_DISABLED: AtomicBool = AtomicBool::new(false); +/// Set after Core accepts one local-path request. Before confirmation, workers +/// share a gate so only one can probe the additive delivery parameter. +static LOCAL_PATH_REQUESTS_CONFIRMED: AtomicBool = AtomicBool::new(false); +static LOCAL_PATH_CAPABILITY_PROBE: AsyncMutex<()> = AsyncMutex::const_new(()); + +enum LocalPathRequestPermit { + Inline, + Request(Option>), +} + +async fn acquire_local_path_request_permit() -> LocalPathRequestPermit { + if LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Acquire) { + return LocalPathRequestPermit::Inline; + } + if LOCAL_PATH_REQUESTS_CONFIRMED.load(Ordering::Acquire) { + return LocalPathRequestPermit::Request(None); + } + + let probe = LOCAL_PATH_CAPABILITY_PROBE.lock().await; + if LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Acquire) { + LocalPathRequestPermit::Inline + } else if LOCAL_PATH_REQUESTS_CONFIRMED.load(Ordering::Acquire) { + LocalPathRequestPermit::Request(None) + } else { + LocalPathRequestPermit::Request(Some(probe)) + } +} /// Hard cap on pending enqueues in the fetch queue. Sized for a few /// dense visual pages (current, lookahead, previous) plus margin, so @@ -1345,7 +1372,7 @@ fn should_request_local_path(max_size: u32) -> bool { max_size, runtime::current().is_mister(), crate::models::core_is_local(), - LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Relaxed), + LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Acquire), ) } @@ -1434,6 +1461,14 @@ async fn fetch_media_image_payload( mut params: MediaImageParams, request_local_path: bool, ) -> Result { + let (request_local_path, probe_guard) = if request_local_path { + match acquire_local_path_request_permit().await { + LocalPathRequestPermit::Inline => (false, None), + LocalPathRequestPermit::Request(probe) => (true, probe), + } + } else { + (false, None) + }; if request_local_path { params.delivery = Some(MEDIA_IMAGE_DELIVERY_LOCAL_PATH.to_string()); } @@ -1444,9 +1479,18 @@ async fn fetch_media_image_payload( let mut path_read_duration = Duration::ZERO; let image = match first { - Ok(image) => image, + Ok(image) => { + if probe_guard.is_some() { + LOCAL_PATH_REQUESTS_CONFIRMED.store(true, Ordering::Release); + } + drop(probe_guard); + image + } Err(error) if request_local_path && is_unsupported_local_path_error(&error.message) => { - LOCAL_PATH_REQUESTS_DISABLED.store(true, Ordering::Relaxed); + // Publish rejection before releasing the probe gate so every + // waiting worker switches directly to legacy inline delivery. + LOCAL_PATH_REQUESTS_DISABLED.store(true, Ordering::Release); + drop(probe_guard); warn!( system_id = %key.system_id, path = %key.path, @@ -1457,7 +1501,10 @@ async fn fetch_media_image_payload( rpc_duration += fallback_duration; fallback? } - Err(error) => return Err(error), + Err(error) => { + drop(probe_guard); + return Err(error); + } }; if image.delivery != MEDIA_IMAGE_DELIVERY_LOCAL_PATH { diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 868afc4d..b3bc5568 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -2725,8 +2725,8 @@ where fn release_model_before_covers(mut model: Pin<&mut ffi::GamesModel>) { let cache = global_media_image_cache(); let page_size = model.page_size.max(1) as usize; - let first = model.rust().visible_first_row.max(0) as usize; - let window_end = (first + page_size).min(model.entries.len()); + let first = (model.rust().visible_first_row.max(0) as usize).min(model.entries.len()); + let window_end = first.saturating_add(page_size).min(model.entries.len()); let visible_entries = &model.entries[first..window_end]; let cover_keys = visible_entries .iter() diff --git a/rust/zaparoo-core/src/client.rs b/rust/zaparoo-core/src/client.rs index c7aa3ad5..5d566290 100644 --- a/rust/zaparoo-core/src/client.rs +++ b/rust/zaparoo-core/src/client.rs @@ -196,6 +196,25 @@ fn deserialize_timed( /// channel that might be drained against a later session. type OutboundSlot = Arc>>>; +#[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] +fn teardown_session(tx_slot: &OutboundSlot, pending: &PendingMap) { + let drained: Vec<_> = { + // Keep session invalidation and pending-request draining in one + // critical section. `call()` uses the same lock while registering and + // sending, so teardown cannot miss a request from the ending session. + let mut sender = tx_slot.lock().unwrap(); + *sender = None; + let drained = pending.lock().unwrap().drain().collect(); + drop(sender); + drained + }; + for (_, response) in drained { + let _ = response.send(Err(ClientError { + message: "disconnected".into(), + })); + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Notification { pub method: String, @@ -427,15 +446,7 @@ impl Client { // queued-but-unsent messages with it) and fail // every pending RPC. The next iteration publishes // `Reconnecting` automatically. - #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] - { - *tx_slot_clone.lock().unwrap() = None; - } - #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] - let drained: Vec<_> = pending_clone.lock().unwrap().drain().collect(); - for (_, tx) in drained { - let _ = tx.send(Err(ClientError { message: "disconnected".into() })); - } + teardown_session(&tx_slot_clone, &pending_clone); } Err(e) => { if let Some(next) = fsm.on_attempt_failed(e.to_string(), boot_window) { @@ -471,29 +482,28 @@ impl Client { })?; let started = Instant::now(); - // Snapshot the current session's sender. If `None`, no live link — - // fail immediately rather than queueing into a channel that will - // be dropped at the next disconnect or, worse, drained by the - // wrong session. - #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] - let sender = self.tx.lock().unwrap().clone().ok_or_else(|| ClientError { - message: "not connected".into(), - })?; - let (resp_tx, resp_rx) = oneshot::channel(); #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] - { + let send_result = { + // Register and send while holding the session lock also used by + // teardown. This makes the sender snapshot and pending entry one + // session-scoped operation: teardown either runs before all three + // steps or drains the newly registered request afterward. + let sender = self.tx.lock().unwrap(); + let sender = sender.as_ref().ok_or_else(|| ClientError { + message: "not connected".into(), + })?; self.pending.lock().unwrap().insert(id.clone(), resp_tx); - } + sender.send(text) + }; let _pending_guard = PendingRequestGuard { id: id.clone(), pending: self.pending.clone(), }; - if sender.send(text).is_err() { - // Receiver was dropped between the snapshot and the send — - // session ended in flight. Clean up the pending entry so it - // doesn't leak. + if send_result.is_err() { + // Receiver dropped unexpectedly while the session still owned its + // sender. Clean up the pending entry so it doesn't leak. #[allow(clippy::unwrap_used, reason = "mutex poisoning is unrecoverable")] { self.pending.lock().unwrap().remove(&id); @@ -928,6 +938,50 @@ mod tests { assert!(pending.lock().unwrap().is_empty()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[allow( + clippy::expect_used, + reason = "concurrency regression test should fail fast on task or timeout errors" + )] + async fn concurrent_call_and_teardown_never_strands_response() { + let (msg_tx, _msg_rx) = mpsc::unbounded_channel(); + let tx_slot: OutboundSlot = Arc::new(Mutex::new(Some(msg_tx))); + let pending = PendingMap::default(); + let (notifications, _) = broadcast::channel(1); + let (connection, _) = watch::channel(ConnectionState::Connected); + let client = Arc::new(Client { + tx: tx_slot.clone(), + pending: pending.clone(), + notifications, + connection: Arc::new(connection), + }); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + + let call_task = tokio::spawn({ + let client = client.clone(); + let barrier = barrier.clone(); + async move { + barrier.wait().await; + client.call("test.concurrent", &Value::Null).await + } + }); + let teardown_task = tokio::spawn(async move { + barrier.wait().await; + teardown_session(&tx_slot, &pending); + }); + + teardown_task.await.expect("teardown task should complete"); + let call_result = tokio::time::timeout(Duration::from_secs(1), call_task) + .await + .expect("call must not remain pending after teardown") + .expect("call task should complete"); + let error = call_result.expect_err("teardown cannot produce a successful response"); + assert!(matches!( + error.message.as_str(), + "disconnected" | "not connected" + )); + } + #[test] fn backoff_follows_exponential_curve_then_caps() { assert_eq!(backoff_delay(0, false), Duration::from_secs(1)); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9ca5ed6b..00bc0193 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,23 +13,25 @@ target_include_directories(frontend_arguments_test PRIVATE "${CMAKE_SOURCE_DIR}/ target_link_libraries(frontend_arguments_test PRIVATE Zaparoo::CompileOptions) add_test(NAME frontend_arguments COMMAND frontend_arguments_test) -set(_VERSION_CHECK_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/check_frontend_version.cmake") -add_test( - NAME frontend_version - COMMAND - "${CMAKE_COMMAND}" -DFRONTEND=$ - -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--version -P "${_VERSION_CHECK_SCRIPT}" -) -add_test( - NAME frontend_version_short - COMMAND - "${CMAKE_COMMAND}" -DFRONTEND=$ - -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=-v -P "${_VERSION_CHECK_SCRIPT}" -) -add_test( - NAME frontend_version_with_crt - COMMAND - "${CMAKE_COMMAND}" -DFRONTEND=$ - -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--crt -DARG2=--version -P - "${_VERSION_CHECK_SCRIPT}" -) +if(NOT CMAKE_CROSSCOMPILING) + set(_VERSION_CHECK_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/check_frontend_version.cmake") + add_test( + NAME frontend_version + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--version -P "${_VERSION_CHECK_SCRIPT}" + ) + add_test( + NAME frontend_version_short + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=-v -P "${_VERSION_CHECK_SCRIPT}" + ) + add_test( + NAME frontend_version_with_crt + COMMAND + "${CMAKE_COMMAND}" -DFRONTEND=$ + -DEXPECTED=${CMAKE_PROJECT_VERSION} -DARG1=--crt -DARG2=--version -P + "${_VERSION_CHECK_SCRIPT}" + ) +endif() diff --git a/tests/ui/tst_resources.qml b/tests/ui/tst_resources.qml index 312c5d26..443cc327 100644 --- a/tests/ui/tst_resources.qml +++ b/tests/ui/tst_resources.qml @@ -103,16 +103,21 @@ TestCase { } function test_missing_system_logo_attempts_load_then_shows_text_on_error(): void { - Resources.systemLogoStyle = "tinted"; - const url = String(Resources.coverUrl("systems/Apogee", "#ffffff", "#888888", "#000000")); - verify(url.startsWith("image://tinted-svg/"), "missing system artwork must still be attempted"); + const originalStyle = Resources.systemLogoStyle; + try { + Resources.systemLogoStyle = "tinted"; + const url = String(Resources.coverUrl("systems/Apogee", "#ffffff", "#888888", "#000000")); + verify(url.startsWith("image://tinted-svg/"), "missing system artwork must still be attempted"); - const host = createTemporaryObject(missingSystemTile, testCase); - verify(host !== null); - const fallback = findChild(host, "tileFallbackText"); - verify(fallback !== null); - compare(fallback.text, "Apogee"); - tryCompare(fallback, "opacity", 1.0, 500); + const host = createTemporaryObject(missingSystemTile, testCase); + verify(host !== null); + const fallback = findChild(host, "tileFallbackText"); + verify(fallback !== null); + compare(fallback.text, "Apogee"); + tryCompare(fallback, "opacity", 1.0, 500); + } finally { + Resources.systemLogoStyle = originalStyle; + } } function test_non_system_image_error_never_shows_text_fallback(): void {