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/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 3305bfbc..438f43af 100644 --- a/rust/frontend/src/media_image_cache.rs +++ b/rust/frontend/src/media_image_cache.rs @@ -34,17 +34,21 @@ 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}; 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::media_types::{MediaImageParams, MediaImageResult}; +use zaparoo_core::client::ClientError; +use zaparoo_core::media_types::{ + MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, +}; +use zaparoo_core::runtime; use zaparoo_core::store::Store; /// Field separator used inside the encoded key. Unit Separator (US, @@ -68,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 @@ -86,6 +94,38 @@ 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); +/// 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 /// an explicit page-window rebuild does not drop the previous-page @@ -877,8 +917,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(); @@ -1320,9 +1360,203 @@ 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 { + local_path_request_allowed( + max_size, + runtime::current().is_mister(), + crate::models::core_is_local(), + LOCAL_PATH_REQUESTS_DISABLED.load(Ordering::Acquire), + ) +} + +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 { + 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 || 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, + params: MediaImageParams, +) -> (Result, Duration) { + let started = Instant::now(); + let result = store + .client() + .media_image(inline_fallback_params(params)) + .await; + (result, started.elapsed()) +} + +async fn fetch_media_image_payload( + store: &Arc, + key: &MediaKey, + 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()); + } + + 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) => { + 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) => { + // 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, + "media_image_cache: Core rejected local-path delivery; using inline for this session" + ); + let (fallback, fallback_duration) = + fetch_inline_media_image(store, params.clone()).await; + rpc_duration += fallback_duration; + fallback? + } + Err(error) => { + drop(probe_guard); + 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" + ); + } + + let (fallback, fallback_duration) = fetch_inline_media_image(store, params).await; + rpc_duration += fallback_duration; + 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 +1591,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 +1600,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 +1637,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 +1711,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 +1744,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 +1754,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 +1966,23 @@ 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, 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 _; 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::{ + MediaImageParams, MediaImageResult, MEDIA_IMAGE_DELIVERY_LOCAL_PATH, + }; /// Build a `MediaImageCache` without spawning the fetch driver. /// Lets tests exercise `enqueue` / `is_cached` / `is_negative` @@ -1736,6 +2003,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(); @@ -1978,6 +2254,84 @@ 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 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"); + 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_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(); + 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()); + } + + #[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 4cafbd6f..7bf58c86 100644 --- a/rust/frontend/src/media_meta_cache.rs +++ b/rust/frontend/src/media_meta_cache.rs @@ -19,20 +19,31 @@ // 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::future::Future; +use std::mem::size_of; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tokio::sync::Semaphore; use tracing::debug; -use zaparoo_core::media_types::{MediaMeta, MediaMetaParams}; +use zaparoo_core::client::ClientError; +use zaparoo_core::media_types::{ + MediaMeta, MediaMetaBatchResult, MediaMetaParams, MediaMetaProperty, MediaMetaResult, 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 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. pub enum MetaLookup { @@ -49,15 +60,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 +82,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, }), } @@ -82,7 +103,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 { @@ -91,71 +112,422 @@ 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 - /// 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`. - 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)); - } + /// 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. + pub fn prefetch(&self, requests: Vec<(MediaKey, MediaMetaParams)>) { + let to_fetch = self.prepare_prefetch(requests); if to_fetch.is_empty() { return; } global_handle().spawn(async move { - let store = global_store(); + let batch_size = to_fetch.len(); + let started = Instant::now(); + 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(); - 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(mut batch) => { + retry_stale_media_ids(&client, &to_fetch, &mut batch).await; + 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( + to_fetch.into_iter().map(|(key, _)| key).collect(), + Some(batch), + ); + } + Err(error) => { + debug!( + batch_size, + duration_ms = started.elapsed().as_millis(), + error = %error.message, + "media_meta_cache: batch prefetch failed" + ); + cache.finish_prefetch(to_fetch.into_iter().map(|(key, _)| key).collect(), 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(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"); + } + } + } + } +} + +/// 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); + } } 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 +542,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 +559,46 @@ mod tests { } } + fn prepared_keys(prepared: &[(MediaKey, MediaMetaParams)]) -> Vec { + 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(); @@ -203,21 +620,199 @@ 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("media not found: NES /mock/b".into()), + }, + ], + }), + ); + assert!(matches!(cache.lookup(&key("a")), MetaLookup::Hit(meta) if meta.path == "a")); + 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(); + 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/mister_runtime.rs b/rust/frontend/src/mister_runtime.rs index ca36437c..a6fa3567 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,114 @@ 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)> { + use tracing::warn; + + let full_result = run_vmode_scale("f", "rgb32"); + let full_size = current_framebuffer_size()?; + if full_result.is_ok() && full_size.1 <= FULL_SIZE_MAX_HEIGHT { + return Some(full_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 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")] +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 +175,193 @@ 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<()> { + // MiSTer's vmode script returns 1 both when res_count confirms a change and + // 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))] +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_result_accepted, 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 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(); + 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/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/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 5e160560..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, @@ -25,11 +27,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 +97,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 +142,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 +153,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 +179,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 +206,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 +276,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 +366,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 +386,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 +400,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 +417,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 +440,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 +462,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 +475,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 +856,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 @@ -956,17 +947,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()); @@ -993,19 +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(MediaMetaParams::for_media(system, path.clone())) - .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; @@ -1120,7 +1113,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 { @@ -1193,10 +1186,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); @@ -1547,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 @@ -1584,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`. @@ -1884,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!( @@ -1933,22 +1783,9 @@ mod tests { } #[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); + fn confirmed_no_cover_skips_image_request() { + let mut entry = favorite_entry(); + entry.has_cover = false; + assert!(media_key_for(&entry).is_none()); } } diff --git a/rust/frontend/src/models/games.rs b/rust/frontend/src/models/games.rs index 20953cf1..b3bc5568 100644 --- a/rust/frontend/src/models/games.rs +++ b/rust/frontend/src/models/games.rs @@ -31,6 +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::{ + 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, @@ -103,23 +106,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 @@ -147,12 +137,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 @@ -235,6 +219,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 @@ -272,36 +259,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 @@ -371,6 +335,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, @@ -379,12 +344,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, @@ -445,6 +406,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)] @@ -475,6 +437,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); @@ -756,13 +721,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 @@ -1219,9 +1192,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 +1209,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 +1222,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 @@ -1307,10 +1308,13 @@ 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); self.as_mut() @@ -1330,76 +1334,35 @@ 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; + let result = + fetch_media_meta_with_path_fallback(params, fallback_system, fallback_path).await; + if let Some(key) = meta_key { + global_media_meta_cache().store_fetch_result(key, &result); + } 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); @@ -1494,7 +1457,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. /// @@ -1517,6 +1480,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); @@ -1548,10 +1516,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 @@ -1561,15 +1525,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 @@ -1579,12 +1539,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; @@ -1834,6 +1788,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 +1907,53 @@ 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 entry = usize::try_from(index) + .ok() + .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() { + 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, @@ -2356,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 @@ -2594,10 +2638,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 @@ -2649,26 +2689,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 @@ -2676,11 +2696,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, @@ -2701,48 +2718,15 @@ 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()); + 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() @@ -2759,73 +2743,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); } } @@ -2990,12 +2910,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); } @@ -3091,10 +3005,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); @@ -3183,6 +3100,90 @@ 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); + // 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); + 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 { @@ -3204,29 +3205,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 @@ -3236,11 +3221,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. @@ -3276,35 +3259,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. @@ -3415,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 @@ -3443,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 @@ -3472,7 +3443,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); + model.as_mut().set_loading_more(false); return; } let mut batches = chunk_for_subbatching(entries, APPEND_SUB_BATCH_SIZE); @@ -3483,7 +3454,7 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); + model.as_mut().set_loading_more(false); return; } // First batch runs synchronously inside the existing @@ -3505,20 +3476,17 @@ fn apply_append_page( if model.total_files != total { model.as_mut().set_total_files(total); } - release_initial_lookahead_gate(model.as_mut()); + model.as_mut().set_loading_more(false); 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). @@ -3543,13 +3511,7 @@ 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()); + model.as_mut().set_loading_more(false); } }); } @@ -3570,12 +3532,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()); } } } @@ -3592,13 +3548,15 @@ 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, is_media_capable_entry, + 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_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, + 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}; @@ -3775,6 +3733,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( @@ -4236,6 +4226,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()]); @@ -4457,12 +4451,20 @@ 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. - // 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")]; @@ -4475,9 +4477,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 b58644dc..62f0730d 100644 --- a/rust/frontend/src/models/recents.rs +++ b/rust/frontend/src/models/recents.rs @@ -17,13 +17,16 @@ // 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}; -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}; @@ -258,6 +261,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); @@ -345,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), @@ -362,7 +377,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(), @@ -638,11 +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(), - }) + .media_history(history_page_params(None)) .await; match &result { Ok(r) => info!( @@ -719,11 +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(), - }) + .media_history(history_page_params(cursor)) .await; let _ = qt_thread.queue(move |model| { if seq.load(Ordering::SeqCst) != ticket { @@ -815,6 +822,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 @@ -888,17 +908,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()); @@ -925,19 +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(MediaMetaParams::for_media(system, path.clone())) - .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; @@ -1126,7 +1148,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 { @@ -1215,10 +1237,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); @@ -1682,16 +1705,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 +1726,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 +1754,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 +1794,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, 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}; use std::collections::HashSet; @@ -1913,6 +1939,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 +1994,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 +2006,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 +2029,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"), @@ -2029,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/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/mock-core/src/fixtures.rs b/rust/mock-core/src/fixtures.rs index 87224ba9..ce4c24d6 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() @@ -313,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); @@ -375,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)); @@ -394,6 +472,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, @@ -404,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 } @@ -449,6 +533,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..7babfd3b 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" => { @@ -189,6 +190,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] @@ -358,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 = @@ -381,22 +394,55 @@ 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 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()); assert!(entry["systemName"].is_string()); 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 c1a9c35c..5d566290 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}; @@ -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, @@ -182,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, @@ -413,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) { @@ -457,31 +482,35 @@ 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); } debug!( method, + request_id = %id, duration_ms = started.elapsed().as_millis(), error = "not connected", "rpc round trip", @@ -499,6 +528,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 +538,7 @@ impl Client { Err(e) => { debug!( method, + request_id = %id, duration_ms = started.elapsed().as_millis(), error = %e.message, "rpc round trip", @@ -641,9 +672,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, @@ -669,6 +700,19 @@ 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 })?; + let val = self.call("media.meta", ¶ms).await?; + deserialize_timed("media.meta batch", val) + } + pub async fn media_history( &self, params: MediaHistoryParams, @@ -879,6 +923,65 @@ 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()); + } + + #[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/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/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/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: "", }, ], diff --git a/rust/zaparoo-core/src/media_types.rs b/rust/zaparoo-core/src/media_types.rs index f655752b..25234256 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, } @@ -593,12 +655,50 @@ 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".to_string()); + } + if items.len() > MEDIA_META_BATCH_MAX_ITEMS { + return Err(format!( + "media.meta batch cannot contain more than {MEDIA_META_BATCH_MAX_ITEMS} 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 { @@ -1188,13 +1288,15 @@ 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, + 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, + MEDIA_META_BATCH_MAX_ITEMS, }; #[test] @@ -1771,6 +1873,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 +1892,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 +1913,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 +1931,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 +1969,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"); } @@ -1893,6 +2010,55 @@ 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 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()); + } + + #[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 @@ -2485,6 +2651,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 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 f59292c4..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; @@ -349,23 +318,34 @@ int main(int argc, char* argv[]) // NOLINT QGuiApplication::setFont(defaultFont); } startupTrace("cpp:font registration complete"); - if (crtNativePathEnabled) + bool useUnsmoothedText = crtNativePathEnabled; +#ifdef ZAPAROO_EMBEDDED_BUILD + 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) { 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); } + 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 @@ -460,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/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..0f93849e 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 : "" @@ -149,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 @@ -189,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; @@ -203,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); } @@ -264,8 +277,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 +319,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 +334,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 +423,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 +478,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 +508,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 +666,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 +781,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 +833,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 +909,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 +936,8 @@ MainLayout { root._completeTransition(root.screenGames); }); } else { - root._prefetchSystemCovers(function () { - root._completeTransition(root.screenSystems); - }); + root.systemsCoverRevealReady = false; + root._completeTransition(root.screenSystems); } }, true); } @@ -1103,7 +1188,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 +1211,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 +1221,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 +1245,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 +1269,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 +1851,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 +2185,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 +2254,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 +2266,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 +2287,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 +2564,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 +2576,6 @@ MainLayout { function cancelPendingRestart(): void { root._pendingLanguageSelection = ""; - root._pendingResolutionSelection = ""; root._pendingCrtStandardSelection = ""; root._pendingCrtToggle = ""; root.closeSettingNeedsRestartModal(); @@ -2525,16 +2597,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 +2781,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 +2835,7 @@ MainLayout { Connections { target: Browse.AppStatus function onConnection_stateChanged(): void { - root._maybeOpenFirstRunIndex(); + root._maybeStartFirstRunIndex(); root._maybeCompleteBoot(); root._maybeStartStartupRestore(); root._maybeCompletePendingResumeLaunch(); @@ -2784,6 +2847,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 +2878,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 +2972,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 +2994,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 +3069,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 +3190,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 +3228,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 +3253,7 @@ MainLayout { root._heldAction = action; root._heldKey = key; repeatTick.stop(); + root._prepareRapidNavigationSnapshot(action); repeatInitial.restart(); } @@ -3437,80 +3538,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 +3608,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..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 @@ -81,8 +82,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 +103,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 +198,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 @@ -241,6 +250,12 @@ ApplicationWindow { value: root.crtNativePath } + Binding { + target: Theme + property: "unsmoothedText" + value: root.unsmoothedText + } + Binding { target: Sizing property: "crtNativePath" @@ -277,7 +292,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 +312,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 +357,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 +446,6 @@ ApplicationWindow { signal closeCommercialNoticeRequested signal closeCoreVersionRequested signal closeRandomFailedRequested - signal closeFirstRunIndexRequested signal closeLogUploadRequested signal closeQuitConfirmRequested signal quitConfirmAccepted @@ -624,8 +649,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 +684,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 +707,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 +921,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 +975,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 +1093,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 +1120,7 @@ ApplicationWindow { label: qsTr("Select") }, { - button: "ButtonB", - label: qsTr("Close") - }, - { - button: "ButtonX", + buttons: ["ButtonB", "ButtonX"], label: qsTr("Close") } ]; @@ -1205,26 +1214,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..5920e631 100644 --- a/src/ui/components/CoreStatusPill.qml +++ b/src/ui/components/CoreStatusPill.qml @@ -19,13 +19,18 @@ 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" 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 +101,29 @@ 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)) + // 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 + pill._textMeasureSlack + readonly property int _desiredWidth: Math.max(pill._minimumWidth, 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 +154,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 4ec3b194..b3b8d471 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 @@ -54,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 @@ -83,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 @@ -144,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 @@ -156,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 @@ -164,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 @@ -222,8 +259,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) @@ -236,14 +272,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)) @@ -252,6 +290,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) @@ -282,11 +329,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) { @@ -370,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 @@ -399,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 @@ -497,6 +553,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; @@ -510,7 +568,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; @@ -579,8 +637,17 @@ Item { root._commitPendingTarget(); } + onLoadingMoreChanged: { + if (!root.loadingMore && root.hasPendingTarget) + root._commitPendingTarget(); + } + 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 +685,7 @@ Item { Repeater { id: itemRepeater - model: root.model + model: root.suspendDelegates ? null : root.model Item { id: cellItem @@ -642,27 +709,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 @@ -674,13 +741,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 { @@ -697,7 +764,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 @@ -785,6 +852,7 @@ Item { isFocused: root.focused name: cellItem.name coverKey: cellItem._gatedCoverKey + topLabel: cellItem.topLabel favorite: cellItem.favorite hidden: cellItem.hidden disambiguatingTags: cellItem.disambiguatingTags @@ -792,6 +860,7 @@ Item { releasePulse: root.releasePulse settling: root.screenSettling focusReady: root.focusReady + loadFocusedCover: root.eagerFocusedCovers || cellItem.isSelected } MouseArea { @@ -841,7 +910,7 @@ Item { anchors.bottom: parent.bottom anchors.bottomMargin: root.bottomInset width: root.gutterWidth - visible: root.totalPageCount > 1 + visible: root._scrollIndicatorVisible Image { id: upArrow @@ -909,6 +978,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..f8a0623a 100644 --- a/src/ui/components/QrCodeModal.qml +++ b/src/ui/components/QrCodeModal.qml @@ -20,74 +20,94 @@ 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 - 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 + open: root.open + kind: "shell" + title: qsTr("Write with QR code") + panelMaxWidth: Sizing.pctH(105) - Item { - id: matrix + Column { + width: parent.width + spacing: Sizing.pctH(2) - 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 + TextMetrics { + id: instructionsMetrics - Repeater { - model: root.matrixSize + font.family: Theme.fontUi + font.pixelSize: Sizing.fontSize(2.4) + text: instructions.text + } - delegate: Item { - id: rowDelegate + Text { + 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.AlignLeft + wrapMode: Text.WordWrap + renderType: Text.NativeRendering + } - required property int index + 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 - readonly property int row: index - readonly property string bits: Browse.QrCode.row_at(row) + Item { + id: matrix - x: 0 - y: row * root.moduleSize - width: matrix.width - height: root.moduleSize + 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 - delegate: Rectangle { + delegate: Item { + id: rowDelegate + required property int index - x: index * root.moduleSize - y: 0 - width: root.moduleSize + 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 - color: "black" - visible: rowDelegate.bits.charAt(index) === "1" + + Repeater { + model: root.matrixSize + + 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" + } + } } } } diff --git a/src/ui/components/ScrollingCaption.qml b/src/ui/components/ScrollingCaption.qml index a75cb84d..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 @@ -52,8 +53,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,18 +99,22 @@ Item { TextMetrics { id: nameMetrics + objectName: "scrollingCaptionNameMetrics" text: root.name font.family: root.fontFamily font.pixelSize: root.fontPixelSize + font.weight: root.fontWeight } TextMetrics { id: tagsMetrics + objectName: "scrollingCaptionTagsMetrics" 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 @@ -156,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 @@ -173,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 bb8ada43..d09e176c 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 @@ -130,7 +133,16 @@ 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) + 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 +163,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 +179,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 +396,35 @@ 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: 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.AlignLeft + 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 +447,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 +491,7 @@ Item { } onStatusChanged: { + coverBase.updateReveal(); if (!root._startupTraceResource) return; if (status === Image.Loading) { @@ -502,41 +529,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 +579,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 @@ -615,6 +615,7 @@ Item { ScrollingCaption { id: caption + objectName: "tileCaption" x: root._captionSideInset y: parent.height - root._captionHeight width: root._captionTextMaxWidth @@ -625,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/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..57b5de97 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,26 @@ 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. + 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 + // 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 9891af22..3518e848 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,26 @@ 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 + 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. + 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 +178,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 +199,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 +510,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 +534,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() @@ -513,6 +564,7 @@ Item { PagedGrid { id: mediaGrid + suspendDelegates: root._listLayout visible: !root._gateHide && !root._listLayout && root.renderGridLayout anchors.left: parent.left anchors.right: parent.right @@ -525,7 +577,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 @@ -533,8 +585,13 @@ Item { rowsOverride: root.gridRowsOverride totalItemsOverride: root.gridTotalItemsOverride hasMorePages: root.gridHasMorePages + loadingMore: root.gridLoadingMore + 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); @@ -565,7 +622,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 @@ -621,6 +678,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..8bc3637f 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. 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 : pctH(31.5), "targetAspect": crtNativePath ? 0.78 : 0.71 }) readonly property var _gamesGridShape: gamesGridShape(screenWidth, screenHeight) 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 150c2e68..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… جارٍ الاستخراج… @@ -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..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… @@ -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..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… Γίνεται συλλογή… @@ -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..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… @@ -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..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… @@ -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..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... @@ -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..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… @@ -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..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… אוסף נתונים… @@ -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..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… स्क्रैपिंग… @@ -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..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… @@ -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..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… スクレイピング中… @@ -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..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… 메타데이터 수집 중… @@ -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..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… @@ -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..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… @@ -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..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… @@ -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..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… Скрейпінг… @@ -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..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… 正在抓取… @@ -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/CMakeLists.txt b/tests/CMakeLists.txt index ae5b51a7..00bc0193 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,3 +3,35 @@ # 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) + +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/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_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..674e15a5 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,45 @@ 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); + 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 +170,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 +300,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 +688,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 e62cdb71..ed807e5f 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++) @@ -77,16 +141,53 @@ 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); grid.setCurrentIndexImmediate(0); 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"); compare(grid.pageSize, 12); + compare(grid._coverRetentionPages, 2, "tile retention must convert cover count to pages"); } function test_empty_model_refuses_movement(): void { @@ -96,6 +197,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); @@ -348,6 +463,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 { @@ -409,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 c427ef5e..443cc327 100644 --- a/tests/ui/tst_resources.qml +++ b/tests/ui/tst_resources.qml @@ -4,15 +4,65 @@ import QtQuick import QtTest +import Zaparoo.Browse as Browse 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 +102,241 @@ TestCase { verify(sys.startsWith("image://tinted-svg/")); } + function test_missing_system_logo_attempts_load_then_shows_text_on_error(): void { + 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); + } finally { + Resources.systemLogoStyle = originalStyle; + } + } + + 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(500); + 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_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); + 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_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" + }); + 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);