From 472706f84eaeb912641af0a512af2cfaf0f01b43 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:33:08 -0600 Subject: [PATCH] fix(settings): harden migration and accessibility Apply interface scaling through Qt at startup, complete keyboard navigation, move account migration into setup and progress modals, and defer Subsonic sync while the server scans. cx --- crates/qbz-account-migration/src/local.rs | 187 ++++++++- crates/qbz-account-migration/src/snapshot.rs | 85 ++++ .../translations/de/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/en/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/es/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 92 +++++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 92 +++++ crates/qbz-qt/build.rs | 2 + crates/qbz-qt/qml/controls/QbzCheckbox.qml | 21 +- .../qbz-qt/qml/controls/QbzConfirmModal.qml | 60 ++- crates/qbz-qt/qml/controls/QbzLineEdit.qml | 13 + crates/qbz-qt/qml/controls/QbzSelect.qml | 84 +++- crates/qbz-qt/qml/controls/QbzSlider.qml | 51 ++- crates/qbz-qt/qml/controls/QbzToggle.qml | 24 +- crates/qbz-qt/qml/controls/SettingsButton.qml | 24 +- .../qbz-qt/qml/settings/CustomThemeEditor.qml | 25 +- crates/qbz-qt/qml/settings/DacWizardModal.qml | 149 ++++++- .../qbz-qt/qml/settings/DiagnosticsPanel.qml | 61 ++- .../qml/settings/ImportExportSettings.qml | 75 +--- .../qml/settings/IntegrationsSettings.qml | 23 +- .../qml/settings/LibFolderEditModal.qml | 45 ++- .../qml/settings/LocalTabsConfigModal.qml | 63 ++- .../qml/settings/MediaServerSettings.qml | 24 +- .../qml/settings/MigrationProgressModal.qml | 347 +++++++++++++++++ .../qml/settings/MigrationSetupModal.qml | 367 ++++++++++++++++++ crates/qbz-qt/qml/settings/PlexSettings.qml | 23 +- crates/qbz-qt/qml/settings/SettingsView.qml | 89 ++++- crates/qbz-qt/qml/shell/AppShell.qml | 5 +- crates/qbz-qt/src/local_bridge.rs | 12 +- crates/qbz-qt/src/main.rs | 22 ++ crates/qbz-qt/src/media_sync_qt.rs | 75 +++- crates/qbz-qt/src/settings_qt.rs | 160 +++++--- .../qbz-qt/src/settings_qt/import_export.rs | 326 ++++++++++++---- crates/qbz-qt/src/shell_bridge.rs | 4 +- crates/qbz-subsonic/src/lib.rs | 55 +++ 38 files changed, 2962 insertions(+), 275 deletions(-) create mode 100644 crates/qbz-qt/qml/settings/MigrationProgressModal.qml create mode 100644 crates/qbz-qt/qml/settings/MigrationSetupModal.qml diff --git a/crates/qbz-account-migration/src/local.rs b/crates/qbz-account-migration/src/local.rs index 27435c48f..0a0626801 100644 --- a/crates/qbz-account-migration/src/local.rs +++ b/crates/qbz-account-migration/src/local.rs @@ -55,6 +55,11 @@ pub struct LocalReport { /// The destination already had library folders: only missing folders /// were added; tracks and per-track links were not copied. pub needs_rescan: bool, + /// Meaningful Plex / Jellyfin / Subsonic connection records present in + /// the source stores. Default placeholder rows are deliberately excluded. + pub media_connections_found: usize, + /// Source connection records whose containing store copied successfully. + pub media_connections_copied: usize, /// Human-readable notes (missing source files, skipped tables). pub notes: Vec, } @@ -121,6 +126,60 @@ fn user_tables(conn: &Connection) -> Result, String> { Ok(names) } +/// Count rows that contain actual connection state rather than the empty +/// placeholder inserted when a settings store is first opened. The candidate +/// columns are intersected with the live schema so this also works with older +/// QBZ profile databases. +fn meaningful_connection_rows( + src_dir: &Path, + file: &str, + table: &str, + candidate_columns: &[&str], +) -> Result { + let path = src_dir.join(file); + if !path.is_file() { + return Ok(0); + } + let conn = open_ro(&path)?; + let present = columns(&conn, table)?; + let present: HashSet<&str> = present.iter().map(String::as_str).collect(); + let selected: Vec<&str> = candidate_columns + .iter() + .copied() + .filter(|column| present.contains(column)) + .collect(); + if selected.is_empty() { + return Ok(0); + } + let projection = selected + .iter() + .map(|column| format!("\"{column}\"")) + .collect::>() + .join(", "); + let mut statement = conn + .prepare(&format!("SELECT {projection} FROM \"{table}\"")) + .map_err(|e| e.to_string())?; + let mut rows = statement.query([]).map_err(|e| e.to_string())?; + let mut count = 0; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + let mut meaningful = false; + for index in 0..selected.len() { + meaningful |= match row.get::<_, Value>(index).map_err(|e| e.to_string())? { + Value::Null => false, + Value::Integer(value) => value != 0, + Value::Real(value) => value != 0.0, + Value::Text(value) => { + let value = value.trim(); + !value.is_empty() && value != "[]" + } + Value::Blob(value) => !value.is_empty(), + }; + } + count += usize::from(meaningful); + } + Ok(count) +} + /// Copy one table by column intersection. `remap` names a column holding /// a source playlist id and the map to translate it; rows whose id is not /// in the map are skipped and counted in `unmapped`. @@ -598,10 +657,50 @@ pub fn copy_profile( } } if options.media_servers { - for file in ["media_servers.db", "plex_settings.db"] { - if let Err(e) = copy_store(src_dir, dst_dir, file, Conflict::Replace, &[], &mut report) - { - report.notes.push(format!("{file}: {e}")); + const CONNECTION_STORES: [(&str, &str, &[&str]); 2] = [ + ( + "media_servers.db", + "media_server_settings", + &[ + "enabled", + "base_url", + "server_name", + "server_id", + "username", + "token", + "password", + "selected_libraries", + ], + ), + ( + "plex_settings.db", + "plex_settings", + &[ + "enabled", + "base_url", + // Pre-profile schema used this name. + "server_url", + "token", + "selected_section_key", + "selected_section_keys", + "machine_id", + ], + ), + ]; + for (file, table, candidate_columns) in CONNECTION_STORES { + let found = match meaningful_connection_rows(src_dir, file, table, candidate_columns) { + Ok(found) => found, + Err(error) => { + report.notes.push(format!( + "{file}: could not inspect connection records: {error}" + )); + 0 + } + }; + report.media_connections_found += found; + match copy_store(src_dir, dst_dir, file, Conflict::Replace, &[], &mut report) { + Ok(()) => report.media_connections_copied += found, + Err(e) => report.notes.push(format!("{file}: {e}")), } } } @@ -816,7 +915,15 @@ mod tests { .unwrap(); let ms_ddl = "CREATE TABLE media_server_settings (server TEXT PRIMARY KEY, token TEXT NOT NULL DEFAULT '');"; db(&src_dir.join("media_servers.db"), ms_ddl) - .execute_batch("INSERT INTO media_server_settings VALUES ('jellyfin', 'secret');") + .execute_batch( + "INSERT INTO media_server_settings VALUES ('jellyfin', 'fixture-token');", + ) + .unwrap(); + let plex_ddl = "CREATE TABLE plex_settings (id INTEGER PRIMARY KEY CHECK (id = 1), server_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '');"; + db(&src_dir.join("plex_settings.db"), plex_ddl) + .execute_batch( + "INSERT INTO plex_settings VALUES (1, 'http://plex.test', 'fixture-plex-token');", + ) .unwrap(); std::fs::write(src_dir.join("lyrics_prefs.json"), "{\"font\":\"x\"}").unwrap(); @@ -856,12 +963,82 @@ mod tests { assert_eq!(flag, 1); // Media servers were opted out: nothing written. assert!(!dst_dir.join("media_servers.db").exists()); + assert!(!dst_dir.join("plex_settings.db").exists()); // JSON sidecar copied because absent. assert_eq!(report.copied["lyrics_prefs.json"], 1); assert!(dst_dir.join("lyrics_prefs.json").is_file()); // Never-copied stores are not even created. assert!(!dst_dir.join("favorites_cache.db").exists()); assert!(!dst_dir.join("subscription_state.db").exists()); + + // A later repair run with the option enabled must still copy both + // server stores, including credentials. This is the local-only rerun + // the UI now allows after the cloud plan reaches zero changes. + let repair = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions::default(), + ) + .unwrap(); + assert_eq!(repair.media_connections_found, 2); + assert_eq!(repair.media_connections_copied, 2); + let media_token: String = Connection::open(dst_dir.join("media_servers.db")) + .unwrap() + .query_row( + "SELECT token FROM media_server_settings WHERE server = 'jellyfin'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(media_token, "fixture-token"); + let plex: (String, String) = Connection::open(dst_dir.join("plex_settings.db")) + .unwrap() + .query_row("SELECT server_url, token FROM plex_settings", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!( + plex, + ("http://plex.test".into(), "fixture-plex-token".into()) + ); + } + + #[test] + fn empty_media_placeholders_are_not_reported_as_copied_connections() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("10"); + let dst_dir = tmp.path().join("20"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let media = "CREATE TABLE media_server_settings ( + server TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, + base_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '', + username TEXT NOT NULL DEFAULT '', password TEXT NOT NULL DEFAULT '', + selected_libraries TEXT NOT NULL DEFAULT '');"; + db(&src_dir.join("media_servers.db"), media) + .execute_batch( + "INSERT INTO media_server_settings (server) VALUES ('jellyfin'), ('subsonic');", + ) + .unwrap(); + let plex = "CREATE TABLE plex_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0, + base_url TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '', + selected_section_keys TEXT NOT NULL DEFAULT '[]');"; + db(&src_dir.join("plex_settings.db"), plex) + .execute_batch("INSERT INTO plex_settings (id) VALUES (1);") + .unwrap(); + + let report = copy_profile( + &src_dir, + &dst_dir, + &Ledger::default(), + LocalOptions::default(), + ) + .unwrap(); + + assert_eq!(report.media_connections_found, 0); + assert_eq!(report.media_connections_copied, 0); } #[test] diff --git a/crates/qbz-account-migration/src/snapshot.rs b/crates/qbz-account-migration/src/snapshot.rs index cf927d4b4..e89a3f453 100644 --- a/crates/qbz-account-migration/src/snapshot.rs +++ b/crates/qbz-account-migration/src/snapshot.rs @@ -154,6 +154,10 @@ pub struct SnapshotSource { pub user_id: u64, #[serde(default)] pub display_name: String, + /// Stored only to help the owner identify bundles belonging to accounts + /// with similar display names. Older snapshots deserialize without it. + #[serde(default)] + pub email: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -180,6 +184,7 @@ impl AccountSnapshot { source: SnapshotSource { user_id, display_name: display_name.to_string(), + email: String::new(), }, favorites: Favorites::default(), playlists: Vec::new(), @@ -316,10 +321,12 @@ pub async fn capture( api: &A, user_id: u64, display_name: &str, + email: &str, sink: &S, ) -> Result { sink.emit(MigrationEvent::Phase(MigrationPhase::ReadingSource)); let mut snap = AccountSnapshot::empty(user_id, display_name); + snap.source.email = email.to_string(); for (i, kind) in FavoriteKind::ALL.iter().enumerate() { sink.emit(MigrationEvent::Progress { done: i, @@ -409,3 +416,81 @@ pub fn list_snapshots(data_root: &Path) -> Vec<(u64, PathBuf)> { .map(|(uid, path, _)| (uid, path)) .collect() } + +/// Delete one snapshot returned by [`list_snapshots`]. +/// +/// The exact-membership check is intentional: the UI passes a path as a +/// string, so accepting an arbitrary path (or merely checking its suffix) +/// would turn a cleanup button into a general file-delete primitive. This +/// removes only the snapshot bundle; the source profile and all of its other +/// user data remain untouched. +pub fn delete_snapshot(data_root: &Path, requested: &Path) -> Result<(), String> { + let allowed = list_snapshots(data_root) + .into_iter() + .any(|(_, path)| path == requested); + if !allowed { + return Err(format!( + "refusing to delete a path outside the migration snapshot list: {}", + requested.display() + )); + } + std::fs::remove_file(requested).map_err(|e| format!("{}: {e}", requested.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "qbz-account-migration-{label}-{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )) + } + + #[test] + fn snapshots_created_before_email_identity_remain_readable() { + let snapshot = AccountSnapshot::empty(42, "Old account"); + let mut value = serde_json::to_value(snapshot).unwrap(); + value["source"].as_object_mut().unwrap().remove("email"); + + let parsed = AccountSnapshot::parse(&value.to_string()).unwrap(); + + assert_eq!(parsed.source.display_name, "Old account"); + assert_eq!(parsed.source.user_id, 42); + assert!(parsed.source.email.is_empty()); + } + + #[test] + fn delete_snapshot_removes_only_a_listed_bundle() { + let root = temp_root("delete"); + let dir = root.join("users/42").join(SNAPSHOT_DIR); + std::fs::create_dir_all(&dir).unwrap(); + let snapshot = dir.join("snapshot-20260902-120000.json"); + let keep = root.join("users/42/profile.db"); + std::fs::write(&snapshot, "{}").unwrap(); + std::fs::write(&keep, "profile").unwrap(); + + delete_snapshot(&root, &snapshot).unwrap(); + + assert!(!snapshot.exists()); + assert!(keep.exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn delete_snapshot_refuses_an_unlisted_path() { + let root = temp_root("refuse"); + let profile = root.join("users/42"); + std::fs::create_dir_all(&profile).unwrap(); + let keep = profile.join("profile.db"); + std::fs::write(&keep, "profile").unwrap(); + + let error = delete_snapshot(&root, &keep).unwrap_err(); + + assert!(error.contains("outside the migration snapshot list")); + assert!(keep.exists()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index e637ae1a1..4c6377980 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6508,3 +6508,95 @@ msgstr "Starte QBZ neu, um das migrierte lokale Profil zu sehen." msgid "The local profile could not be copied; see the log." msgstr "Das lokale Profil konnte nicht kopiert werden; siehe Log." +msgid "Delete…" +msgstr "Löschen…" + +msgid "Delete migration snapshot?" +msgstr "Migrations-Snapshot löschen?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Dadurch wird nur das Migrationspaket entfernt. Das QBZ-Profil und die Benutzerdaten des alten Kontos bleiben erhalten." + +msgid "Delete snapshot" +msgstr "Snapshot löschen" + +msgid "Migration snapshot deleted." +msgstr "Migrations-Snapshot gelöscht." + +msgid "Could not delete the migration snapshot." +msgstr "Der Migrations-Snapshot konnte nicht gelöscht werden." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} aktualisiert gerade seine Bibliothek ({} Elemente gescannt). Warten Sie, bis der Vorgang abgeschlossen ist, und synchronisieren Sie dann erneut." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Im Quellprofil war keine Plex-, Jellyfin- oder Subsonic-Verbindung gespeichert, daher konnte keine kopiert werden." + +msgid "Media server connections copied: {} of {}." +msgstr "Kopierte Medienserver-Verbindungen: {} von {}." + +msgid "Copying local profile…" +msgstr "Lokales Profil wird kopiert…" + +msgid "Refreshing migrated data…" +msgstr "Migrierte Daten werden aktualisiert…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Dies geschieht nur einmal. Bitte warten Sie, bis der Vorgang abgeschlossen ist, und schließen Sie das Fenster nicht." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Sie können dieses Fenster schließen; die Migration wird im Hintergrund fortgesetzt." + +msgid "From" +msgstr "Von" + +msgid "To" +msgstr "Nach" + +msgid "Step {} of {}" +msgstr "Schritt {} von {}" + +msgid "Elapsed: {}" +msgstr "Vergangene Zeit: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Prüfen Sie die gespeicherten Quellprofile, wählen Sie die zu kopierenden Daten und bestätigen Sie das angemeldete Zielkonto." + +msgid "Migrate account data" +msgstr "Kontodaten migrieren" + +msgid "Start migration" +msgstr "Migration starten" + +msgid "What is a migration profile?" +msgstr "Was ist ein Migrationsprofil?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Es ist ein einmaliges Paket aus dem Quellkonto. Es bewahrt dessen Qobuz-Favoriten und Playlists, damit sie dem aktuell angemeldeten Konto hinzugefügt werden können. Optionale lokale Daten werden direkt aus dem alten QBZ-Profil auf diesem Computer kopiert. Das Löschen des Pakets löscht weder das alte Profil noch Kontodaten." + +msgid "SAVED SOURCE PROFILES" +msgstr "GESPEICHERTE QUELLPROFILE" + +msgid "No saved migration profiles are available." +msgstr "Es sind keine gespeicherten Migrationsprofile verfügbar." + +msgid "Selected" +msgstr "Ausgewählt" + +msgid "Select" +msgstr "Auswählen" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATIONSRICHTUNG" + +msgid "Select a source profile above" +msgstr "Oben ein Quellprofil auswählen" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Angemeldetes Qobuz-Konto · ID {}" + +msgid "WHAT TO COPY" +msgstr "ZU KOPIERENDE DATEN" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Die Migration ist additiv: Sie entfernt keine Favoriten oder Playlists aus einem der Konten." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 771c3bfa2..a41b74bf4 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -2095,3 +2095,95 @@ msgstr "Restart QBZ to see the migrated local profile." msgid "The local profile could not be copied; see the log." msgstr "The local profile could not be copied; see the log." +msgid "Delete…" +msgstr "Delete…" + +msgid "Delete migration snapshot?" +msgstr "Delete migration snapshot?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "This removes only the migration bundle. The old account's QBZ profile and user data are kept." + +msgid "Delete snapshot" +msgstr "Delete snapshot" + +msgid "Migration snapshot deleted." +msgstr "Migration snapshot deleted." + +msgid "Could not delete the migration snapshot." +msgstr "Could not delete the migration snapshot." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." + +msgid "Media server connections copied: {} of {}." +msgstr "Media server connections copied: {} of {}." + +msgid "Copying local profile…" +msgstr "Copying local profile…" + +msgid "Refreshing migrated data…" +msgstr "Refreshing migrated data…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "This only happens once. Please wait for it to finish and do not close the window." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "You can close this panel; the migration will continue in the background." + +msgid "From" +msgstr "From" + +msgid "To" +msgstr "To" + +msgid "Step {} of {}" +msgstr "Step {} of {}" + +msgid "Elapsed: {}" +msgstr "Elapsed: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." + +msgid "Migrate account data" +msgstr "Migrate account data" + +msgid "Start migration" +msgstr "Start migration" + +msgid "What is a migration profile?" +msgstr "What is a migration profile?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." + +msgid "SAVED SOURCE PROFILES" +msgstr "SAVED SOURCE PROFILES" + +msgid "No saved migration profiles are available." +msgstr "No saved migration profiles are available." + +msgid "Selected" +msgstr "Selected" + +msgid "Select" +msgstr "Select" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATION DIRECTION" + +msgid "Select a source profile above" +msgstr "Select a source profile above" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Signed-in Qobuz account · ID {}" + +msgid "WHAT TO COPY" +msgstr "WHAT TO COPY" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Migration is additive: it does not remove favorites or playlists from either account." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 54889ee4c..413e69eab 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6514,3 +6514,95 @@ msgstr "Reinicia QBZ para ver el perfil local migrado." msgid "The local profile could not be copied; see the log." msgstr "No se pudo copiar el perfil local; checa el log." +msgid "Delete…" +msgstr "Eliminar…" + +msgid "Delete migration snapshot?" +msgstr "¿Eliminar la instantánea de migración?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Esto elimina únicamente el paquete de migración. Se conservarán el perfil de QBZ y los datos de usuario de la cuenta anterior." + +msgid "Delete snapshot" +msgstr "Eliminar instantánea" + +msgid "Migration snapshot deleted." +msgstr "Se eliminó la instantánea de migración." + +msgid "Could not delete the migration snapshot." +msgstr "No se pudo eliminar la instantánea de migración." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} está actualizando su biblioteca ({} elementos escaneados). Espera a que termine y vuelve a sincronizar." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "No había ninguna conexión de Plex, Jellyfin o Subsonic guardada en el perfil de origen, así que no se pudo copiar ninguna." + +msgid "Media server connections copied: {} of {}." +msgstr "Conexiones a servidores de medios copiadas: {} de {}." + +msgid "Copying local profile…" +msgstr "Copiando el perfil local…" + +msgid "Refreshing migrated data…" +msgstr "Actualizando los datos migrados…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Esto solo sucede una vez. Espera a que termine y no cierres la ventana." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Puedes cerrar este panel; la migración continuará en segundo plano." + +msgid "From" +msgstr "Origen" + +msgid "To" +msgstr "Destino" + +msgid "Step {} of {}" +msgstr "Paso {} de {}" + +msgid "Elapsed: {}" +msgstr "Tiempo transcurrido: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Revisa los perfiles de origen guardados, elige qué copiar y confirma la cuenta de destino con la sesión iniciada." + +msgid "Migrate account data" +msgstr "Migrar datos de la cuenta" + +msgid "Start migration" +msgstr "Iniciar migración" + +msgid "What is a migration profile?" +msgstr "¿Qué es un perfil de migración?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Es un paquete de un solo uso creado desde la cuenta de origen. Conserva sus favoritos y playlists de Qobuz para agregarlos a la cuenta que tiene la sesión iniciada. Los datos locales opcionales se copian directamente desde el perfil anterior de QBZ en esta computadora. Eliminar el paquete no borra el perfil anterior ni los datos de ninguna cuenta." + +msgid "SAVED SOURCE PROFILES" +msgstr "PERFILES DE ORIGEN GUARDADOS" + +msgid "No saved migration profiles are available." +msgstr "No hay perfiles de migración guardados disponibles." + +msgid "Selected" +msgstr "Seleccionado" + +msgid "Select" +msgstr "Seleccionar" + +msgid "MIGRATION DIRECTION" +msgstr "DIRECCIÓN DE LA MIGRACIÓN" + +msgid "Select a source profile above" +msgstr "Selecciona arriba un perfil de origen" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Cuenta de Qobuz con sesión iniciada · ID {}" + +msgid "WHAT TO COPY" +msgstr "QUÉ COPIAR" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "La migración es aditiva: no elimina favoritos ni playlists de ninguna cuenta." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index c4d3c0242..82a1c23cb 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6506,3 +6506,95 @@ msgstr "Redémarrez QBZ pour voir le profil local migré." msgid "The local profile could not be copied; see the log." msgstr "Le profil local n'a pas pu être copié ; voir le journal." +msgid "Delete…" +msgstr "Supprimer…" + +msgid "Delete migration snapshot?" +msgstr "Supprimer l'instantané de migration ?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Cela supprime uniquement le paquet de migration. Le profil QBZ et les données utilisateur de l'ancien compte sont conservés." + +msgid "Delete snapshot" +msgstr "Supprimer l'instantané" + +msgid "Migration snapshot deleted." +msgstr "Instantané de migration supprimé." + +msgid "Could not delete the migration snapshot." +msgstr "Impossible de supprimer l'instantané de migration." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} actualise sa bibliothèque ({} éléments analysés). Attendez la fin de l'opération, puis relancez la synchronisation." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Aucune connexion Plex, Jellyfin ou Subsonic n'était enregistrée dans le profil source, aucune n'a donc pu être copiée." + +msgid "Media server connections copied: {} of {}." +msgstr "Connexions aux serveurs multimédias copiées : {} sur {}." + +msgid "Copying local profile…" +msgstr "Copie du profil local…" + +msgid "Refreshing migrated data…" +msgstr "Actualisation des données migrées…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Cette opération n'a lieu qu'une fois. Attendez qu'elle se termine et ne fermez pas la fenêtre." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Vous pouvez fermer ce panneau ; la migration se poursuivra en arrière-plan." + +msgid "From" +msgstr "Source" + +msgid "To" +msgstr "Destination" + +msgid "Step {} of {}" +msgstr "Étape {} sur {}" + +msgid "Elapsed: {}" +msgstr "Temps écoulé : {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Vérifiez les profils sources enregistrés, choisissez les données à copier et confirmez le compte de destination connecté." + +msgid "Migrate account data" +msgstr "Migrer les données du compte" + +msgid "Start migration" +msgstr "Démarrer la migration" + +msgid "What is a migration profile?" +msgstr "Qu’est-ce qu’un profil de migration ?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Il s’agit d’un paquet à usage unique créé depuis le compte source. Il conserve ses favoris et playlists Qobuz afin de les ajouter au compte actuellement connecté. Les données locales facultatives sont copiées directement depuis l’ancien profil QBZ de cet ordinateur. Supprimer le paquet ne supprime ni l’ancien profil ni les données des comptes." + +msgid "SAVED SOURCE PROFILES" +msgstr "PROFILS SOURCES ENREGISTRÉS" + +msgid "No saved migration profiles are available." +msgstr "Aucun profil de migration enregistré n’est disponible." + +msgid "Selected" +msgstr "Sélectionné" + +msgid "Select" +msgstr "Sélectionner" + +msgid "MIGRATION DIRECTION" +msgstr "DIRECTION DE LA MIGRATION" + +msgid "Select a source profile above" +msgstr "Sélectionnez un profil source ci-dessus" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Compte Qobuz connecté · ID {}" + +msgid "WHAT TO COPY" +msgstr "DONNÉES À COPIER" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "La migration est additive : elle ne supprime aucun favori ni aucune playlist des deux comptes." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 67d7becc8..aaea0e1a2 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -6489,3 +6489,95 @@ msgstr "移行したローカルプロファイルを表示するには QBZ を msgid "The local profile could not be copied; see the log." msgstr "ローカルプロファイルをコピーできませんでした。ログを確認してください。" +msgid "Delete…" +msgstr "削除…" + +msgid "Delete migration snapshot?" +msgstr "移行スナップショットを削除しますか?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "移行バンドルのみを削除します。以前のアカウントのQBZプロファイルとユーザーデータは保持されます。" + +msgid "Delete snapshot" +msgstr "スナップショットを削除" + +msgid "Migration snapshot deleted." +msgstr "移行スナップショットを削除しました。" + +msgid "Could not delete the migration snapshot." +msgstr "移行スナップショットを削除できませんでした。" + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} はライブラリを更新中です({} 件をスキャン済み)。完了してからもう一度同期してください。" + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "移行元プロファイルに Plex、Jellyfin、Subsonic の接続が保存されていなかったため、コピーできませんでした。" + +msgid "Media server connections copied: {} of {}." +msgstr "メディアサーバー接続のコピー:{} / {}。" + +msgid "Copying local profile…" +msgstr "ローカルプロファイルをコピーしています…" + +msgid "Refreshing migrated data…" +msgstr "移行したデータを更新しています…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "この処理は一度だけ行われます。完了するまで待ち、ウィンドウを閉じないでください。" + +msgid "You can close this panel; the migration will continue in the background." +msgstr "このパネルを閉じても、移行はバックグラウンドで続行されます。" + +msgid "From" +msgstr "移行元" + +msgid "To" +msgstr "移行先" + +msgid "Step {} of {}" +msgstr "ステップ {} / {}" + +msgid "Elapsed: {}" +msgstr "経過時間:{}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "保存済みの移行元プロファイルを確認し、コピーする項目を選択して、現在サインイン中の移行先アカウントを確認します。" + +msgid "Migrate account data" +msgstr "アカウントデータを移行" + +msgid "Start migration" +msgstr "移行を開始" + +msgid "What is a migration profile?" +msgstr "移行プロファイルとは?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "移行元アカウントから作成される一度限りのバンドルです。そのアカウントのQobuzのお気に入りとプレイリストを保存し、現在サインイン中のアカウントに追加できます。任意のローカルデータは、このコンピューター上の古いQBZプロファイルから直接コピーされます。バンドルを削除しても、古いプロファイルや各アカウントのデータは削除されません。" + +msgid "SAVED SOURCE PROFILES" +msgstr "保存済みの移行元プロファイル" + +msgid "No saved migration profiles are available." +msgstr "利用できる保存済み移行プロファイルはありません。" + +msgid "Selected" +msgstr "選択済み" + +msgid "Select" +msgstr "選択" + +msgid "MIGRATION DIRECTION" +msgstr "移行方向" + +msgid "Select a source profile above" +msgstr "上で移行元プロファイルを選択してください" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "サインイン中のQobuzアカウント · ID {}" + +msgid "WHAT TO COPY" +msgstr "コピーする項目" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "移行は追加のみです。どちらのアカウントからもお気に入りやプレイリストは削除されません。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index 2572beb5d..4586a9b13 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6496,3 +6496,95 @@ msgstr "Herstart QBZ om het gemigreerde lokale profiel te zien." msgid "The local profile could not be copied; see the log." msgstr "Het lokale profiel kon niet worden gekopieerd; zie het logboek." +msgid "Delete…" +msgstr "Verwijderen…" + +msgid "Delete migration snapshot?" +msgstr "Migratiemomentopname verwijderen?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Hiermee wordt alleen de migratiebundel verwijderd. Het QBZ-profiel en de gebruikersgegevens van het oude account blijven behouden." + +msgid "Delete snapshot" +msgstr "Momentopname verwijderen" + +msgid "Migration snapshot deleted." +msgstr "Migratiemomentopname verwijderd." + +msgid "Could not delete the migration snapshot." +msgstr "Kon de migratiemomentopname niet verwijderen." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} vernieuwt de bibliotheek ({} items gescand). Wacht tot dit klaar is en synchroniseer daarna opnieuw." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "In het bronprofiel was geen Plex-, Jellyfin- of Subsonic-verbinding opgeslagen, dus er kon niets worden gekopieerd." + +msgid "Media server connections copied: {} of {}." +msgstr "Gekopieerde mediaserververbindingen: {} van {}." + +msgid "Copying local profile…" +msgstr "Lokaal profiel kopiëren…" + +msgid "Refreshing migrated data…" +msgstr "Gemigreerde gegevens vernieuwen…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Dit gebeurt maar één keer. Wacht tot het klaar is en sluit het venster niet." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "U kunt dit paneel sluiten; de migratie gaat op de achtergrond verder." + +msgid "From" +msgstr "Bron" + +msgid "To" +msgstr "Doel" + +msgid "Step {} of {}" +msgstr "Stap {} van {}" + +msgid "Elapsed: {}" +msgstr "Verstreken: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Controleer de opgeslagen bronprofielen, kies wat u wilt kopiëren en bevestig het aangemelde doelaccount." + +msgid "Migrate account data" +msgstr "Accountgegevens migreren" + +msgid "Start migration" +msgstr "Migratie starten" + +msgid "What is a migration profile?" +msgstr "Wat is een migratieprofiel?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Het is een eenmalige bundel die vanuit het bronaccount is gemaakt. De Qobuz-favorieten en afspeellijsten blijven erin bewaard, zodat ze aan het nu aangemelde account kunnen worden toegevoegd. Optionele lokale gegevens worden rechtstreeks uit het oude QBZ-profiel op deze computer gekopieerd. Het verwijderen van de bundel verwijdert het oude profiel of accountgegevens niet." + +msgid "SAVED SOURCE PROFILES" +msgstr "OPGESLAGEN BRONPROFIELEN" + +msgid "No saved migration profiles are available." +msgstr "Er zijn geen opgeslagen migratieprofielen beschikbaar." + +msgid "Selected" +msgstr "Geselecteerd" + +msgid "Select" +msgstr "Selecteren" + +msgid "MIGRATION DIRECTION" +msgstr "MIGRATIERICHTING" + +msgid "Select a source profile above" +msgstr "Selecteer hierboven een bronprofiel" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Aangemeld Qobuz-account · ID {}" + +msgid "WHAT TO COPY" +msgstr "WAT KOPIËREN" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "De migratie is aanvullend: favorieten en afspeellijsten worden uit geen van beide accounts verwijderd." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 62391e4c1..6645fc139 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6506,3 +6506,95 @@ msgstr "Reinicie o QBZ para ver o perfil local migrado." msgid "The local profile could not be copied; see the log." msgstr "Não foi possível copiar o perfil local; veja o log." +msgid "Delete…" +msgstr "Excluir…" + +msgid "Delete migration snapshot?" +msgstr "Excluir instantâneo de migração?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Isso remove apenas o pacote de migração. O perfil do QBZ e os dados de usuário da conta antiga são mantidos." + +msgid "Delete snapshot" +msgstr "Excluir instantâneo" + +msgid "Migration snapshot deleted." +msgstr "Instantâneo de migração excluído." + +msgid "Could not delete the migration snapshot." +msgstr "Não foi possível excluir o instantâneo de migração." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} está atualizando a biblioteca ({} itens verificados). Aguarde a conclusão e sincronize novamente." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "Nenhuma conexão Plex, Jellyfin ou Subsonic estava salva no perfil de origem, portanto nenhuma pôde ser copiada." + +msgid "Media server connections copied: {} of {}." +msgstr "Conexões de servidores de mídia copiadas: {} de {}." + +msgid "Copying local profile…" +msgstr "Copiando o perfil local…" + +msgid "Refreshing migrated data…" +msgstr "Atualizando os dados migrados…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Isso acontece apenas uma vez. Aguarde a conclusão e não feche a janela." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Você pode fechar este painel; a migração continuará em segundo plano." + +msgid "From" +msgstr "Origem" + +msgid "To" +msgstr "Destino" + +msgid "Step {} of {}" +msgstr "Etapa {} de {}" + +msgid "Elapsed: {}" +msgstr "Tempo decorrido: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Revise os perfis de origem salvos, escolha o que copiar e confirme a conta de destino conectada." + +msgid "Migrate account data" +msgstr "Migrar dados da conta" + +msgid "Start migration" +msgstr "Iniciar migração" + +msgid "What is a migration profile?" +msgstr "O que é um perfil de migração?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "É um pacote de uso único criado a partir da conta de origem. Ele preserva os favoritos e playlists do Qobuz para adicioná-los à conta conectada agora. Os dados locais opcionais são copiados diretamente do perfil antigo do QBZ neste computador. Excluir o pacote não apaga o perfil antigo nem os dados de nenhuma conta." + +msgid "SAVED SOURCE PROFILES" +msgstr "PERFIS DE ORIGEM SALVOS" + +msgid "No saved migration profiles are available." +msgstr "Não há perfis de migração salvos disponíveis." + +msgid "Selected" +msgstr "Selecionado" + +msgid "Select" +msgstr "Selecionar" + +msgid "MIGRATION DIRECTION" +msgstr "DIREÇÃO DA MIGRAÇÃO" + +msgid "Select a source profile above" +msgstr "Selecione um perfil de origem acima" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Conta Qobuz conectada · ID {}" + +msgid "WHAT TO COPY" +msgstr "O QUE COPIAR" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "A migração é aditiva: ela não remove favoritos nem playlists de nenhuma conta." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 29a354583..ecf835215 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6527,3 +6527,95 @@ msgstr "Перезапустите QBZ, чтобы увидеть перенес msgid "The local profile could not be copied; see the log." msgstr "Не удалось скопировать локальный профиль; см. журнал." +msgid "Delete…" +msgstr "Удалить…" + +msgid "Delete migration snapshot?" +msgstr "Удалить снимок для переноса?" + +msgid "This removes only the migration bundle. The old account's QBZ profile and user data are kept." +msgstr "Будет удалён только пакет переноса. Профиль QBZ и пользовательские данные старой учётной записи сохранятся." + +msgid "Delete snapshot" +msgstr "Удалить снимок" + +msgid "Migration snapshot deleted." +msgstr "Снимок для переноса удалён." + +msgid "Could not delete the migration snapshot." +msgstr "Не удалось удалить снимок для переноса." + +msgid "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again." +msgstr "{} обновляет свою медиатеку (просканировано элементов: {}). Дождитесь завершения и повторите синхронизацию." + +msgid "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied." +msgstr "В исходном профиле не было сохранено подключений Plex, Jellyfin или Subsonic, поэтому копировать было нечего." + +msgid "Media server connections copied: {} of {}." +msgstr "Подключения к медиасерверам скопированы: {} из {}." + +msgid "Copying local profile…" +msgstr "Копирование локального профиля…" + +msgid "Refreshing migrated data…" +msgstr "Обновление перенесённых данных…" + +msgid "This only happens once. Please wait for it to finish and do not close the window." +msgstr "Это происходит только один раз. Дождитесь завершения и не закрывайте окно." + +msgid "You can close this panel; the migration will continue in the background." +msgstr "Эту панель можно закрыть; перенос продолжится в фоновом режиме." + +msgid "From" +msgstr "Источник" + +msgid "To" +msgstr "Назначение" + +msgid "Step {} of {}" +msgstr "Шаг {} из {}" + +msgid "Elapsed: {}" +msgstr "Прошло времени: {}" + +msgid "Review the saved source profiles, choose what to copy, and confirm the signed-in destination account." +msgstr "Проверьте сохранённые исходные профили, выберите данные для копирования и подтвердите целевой аккаунт, в который выполнен вход." + +msgid "Migrate account data" +msgstr "Перенос данных аккаунта" + +msgid "Start migration" +msgstr "Начать перенос" + +msgid "What is a migration profile?" +msgstr "Что такое профиль переноса?" + +msgid "It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data." +msgstr "Это одноразовый пакет, созданный из исходного аккаунта. В нём сохраняются избранное и плейлисты Qobuz, чтобы добавить их в аккаунт, в который выполнен вход. Дополнительные локальные данные копируются напрямую из старого профиля QBZ на этом компьютере. Удаление пакета не удаляет старый профиль или данные аккаунтов." + +msgid "SAVED SOURCE PROFILES" +msgstr "СОХРАНЁННЫЕ ИСХОДНЫЕ ПРОФИЛИ" + +msgid "No saved migration profiles are available." +msgstr "Нет доступных сохранённых профилей переноса." + +msgid "Selected" +msgstr "Выбрано" + +msgid "Select" +msgstr "Выбрать" + +msgid "MIGRATION DIRECTION" +msgstr "НАПРАВЛЕНИЕ ПЕРЕНОСА" + +msgid "Select a source profile above" +msgstr "Выберите исходный профиль выше" + +msgid "Signed-in Qobuz account · ID {}" +msgstr "Аккаунт Qobuz, в который выполнен вход · ID {}" + +msgid "WHAT TO COPY" +msgstr "ЧТО КОПИРОВАТЬ" + +msgid "Migration is additive: it does not remove favorites or playlists from either account." +msgstr "Перенос только добавляет данные: избранное и плейлисты не удаляются ни из одного аккаунта." diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 6ef11cadf..2673be0cf 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -796,6 +796,8 @@ fn main() { "qml/settings/DacWizardModal.qml", "qml/settings/DeveloperSettings.qml", "qml/settings/ImportExportSettings.qml", + "qml/settings/MigrationSetupModal.qml", + "qml/settings/MigrationProgressModal.qml", "qml/settings/DiagnosticsPanel.qml", "qml/settings/LibFolderEditModal.qml", "qml/settings/LibraryFolderTable.qml", diff --git a/crates/qbz-qt/qml/controls/QbzCheckbox.qml b/crates/qbz-qt/qml/controls/QbzCheckbox.qml index 910aa6349..6ec8aea2a 100644 --- a/crates/qbz-qt/qml/controls/QbzCheckbox.qml +++ b/crates/qbz-qt/qml/controls/QbzCheckbox.qml @@ -20,8 +20,24 @@ Rectangle { height: 18 radius: 4 color: root.checked ? theme.accent : "transparent" - border.width: root.checked ? 0 : 2 - border.color: theme.textMuted + border.width: root.activeFocus ? 2 : (root.checked ? 0 : 2) + border.color: root.activeFocus + ? (root.checked ? theme.accentGlyphColor : theme.accent) + : theme.textMuted + activeFocusOnTab: root.enabled + Accessible.role: Accessible.CheckBox + Accessible.checked: root.checked + Accessible.onToggleAction: if (root.enabled) root.toggled() + + Keys.onPressed: function (event) { + if (root.enabled && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.toggled() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent @@ -46,6 +62,7 @@ Rectangle { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor + onPressed: root.forceActiveFocus() onClicked: root.toggled() } } diff --git a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml index c3aee3ce6..bb6e2b746 100644 --- a/crates/qbz-qt/qml/controls/QbzConfirmModal.qml +++ b/crates/qbz-qt/qml/controls/QbzConfirmModal.qml @@ -70,16 +70,16 @@ Item { root._open = true // AFTER _open, so `visible` is already true — an invisible item cannot // take active focus and the Esc handler would be dead. - keyScope.forceActiveFocus() + Qt.callLater(function () { cancelButton.forceActiveFocus() }) } function close() { root._open = false root._restoreShellFocus() } - // §1.4.3 (2026-08-03 hotkeys-port contract): keyScope GRABS focus on open - // and Qt strands activeFocus on the now-invisible item at close, which - // kills the AppShell key dispatcher until the next click. Restore the - // shell root — the dispatcher's fallback focus item — on every close + // §1.4.3 (2026-08-03 hotkeys-port contract): a modal control GRABS focus + // on open and Qt strands activeFocus on that now-invisible item at close, + // which kills the AppShell key dispatcher until the next click. Restore + // the shell root — the dispatcher's fallback focus item — on every close // path. Duck-walk, because this modal mounts under several parents (the // Blacklist manager, FolderModals, MyQBZ delete). function _restoreShellFocus() { @@ -105,15 +105,9 @@ Item { enabled: root._open z: 3100 - // Esc dismiss (the Slint esc-scope FocusScope, :1006-1017). No visual and - // no MouseArea, so it never intercepts a press meant for the scrim. - FocusScope { - id: keyScope - anchors.fill: parent - Keys.onEscapePressed: function (event) { - root._cancel() - event.accepted = true - } + Keys.onEscapePressed: function (event) { + root._cancel() + event.accepted = true } // Scrim. `radius` is load-bearing, not decoration: this Item fills the @@ -197,11 +191,29 @@ Item { spacing: 10 Rectangle { + id: cancelButton width: cancelLbl.implicitWidth + 32 height: 36 radius: theme.radiusSm color: cancelArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + activeFocusOnTab: root.opened + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: root.cancelLabel + Accessible.onPressAction: root._cancel() + KeyNavigation.tab: confirmButton + KeyNavigation.backtab: confirmButton + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root._cancel() + event.accepted = true + } + } Text { id: cancelLbl anchors.centerIn: parent @@ -214,10 +226,12 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: cancelButton.forceActiveFocus() onClicked: root._cancel() } } Rectangle { + id: confirmButton width: confirmLbl.implicitWidth + 32 height: 36 radius: theme.radiusSm @@ -225,6 +239,23 @@ Item { ? (confirmArea.containsMouse ? Qt.darker(theme.danger, 1.12) : theme.danger) : (confirmArea.containsMouse ? theme.accentHover : theme.accent) + activeFocusOnTab: root.opened + border.width: activeFocus ? 2 : 0 + border.color: root.danger ? "#ffffff" : theme.accentGlyphColor + Accessible.role: Accessible.Button + Accessible.name: root.confirmLabel + Accessible.onPressAction: root._confirm() + KeyNavigation.tab: cancelButton + KeyNavigation.backtab: cancelButton + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root._confirm() + event.accepted = true + } + } Text { id: confirmLbl anchors.centerIn: parent @@ -244,6 +275,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: confirmButton.forceActiveFocus() onClicked: root._confirm() } } diff --git a/crates/qbz-qt/qml/controls/QbzLineEdit.qml b/crates/qbz-qt/qml/controls/QbzLineEdit.qml index 0f4b92183..2b7be4b79 100644 --- a/crates/qbz-qt/qml/controls/QbzLineEdit.qml +++ b/crates/qbz-qt/qml/controls/QbzLineEdit.qml @@ -62,6 +62,18 @@ Rectangle { width: expandable ? collapsedSize : 240 height: (searchMode || expandable) ? collapsedSize : 34 color: "transparent" + activeFocusOnTab: root.enabled && root.expandable && !root.open + + Keys.onPressed: function (event) { + if (root.enabled && root.expandable && !root.open + && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.open = true + event.accepted = true + } + } /// Set by `clearSearch()`, released the moment the CALLER publishes anything /// into `text`. While it is set, the focus-loss re-seed Binding below is @@ -212,6 +224,7 @@ Rectangle { verticalAlignment: Text.AlignVCenter clip: true selectByMouse: true + activeFocusOnTab: root.enabled && (!root.expandable || root.open) echoMode: root.isPassword ? TextInput.Password : TextInput.Normal text: root.text onAccepted: { root.accepted(text); root.committed(text) } diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index a1df927fc..829407690 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -38,8 +38,8 @@ Rectangle { width: sm ? Math.max(0, menuWidth - 40) : menuWidth height: sm ? 30 : 34 radius: sm ? 6 : theme.radiusSm - border.width: sm ? 0 : 1 - border.color: theme.borderSubtle + border.width: selectRoot.activeFocus ? 2 : (sm ? 0 : 1) + border.color: selectRoot.activeFocus ? theme.accent : theme.borderSubtle // Resting fill goes translucent under the dynamic background so the field // shows through the control (QbzSelect.slint:110-116). Hover keeps its own // token, which is already translucent. @@ -47,6 +47,33 @@ Rectangle { ? theme.surfaceHover : (theme.ambientOn ? theme.surfaceElevatedA50 : theme.surfaceElevated) opacity: enabled ? 1.0 : 0.4 + activeFocusOnTab: enabled + Accessible.role: Accessible.ComboBox + Accessible.name: currentIndex >= 0 && currentIndex < options.length + ? optLabel(currentIndex) : "" + Accessible.onPressAction: selectRoot.openPopup() + + Keys.onPressed: function (event) { + if (!selectRoot.enabled || event.isAutoRepeat) + return + if (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter) { + if (popup.opened) + popup.close() + else + selectRoot.openPopup() + } else if (event.key === Qt.Key_Up && selectRoot.currentIndex > 0) { + selectRoot.selected(selectRoot.currentIndex - 1) + } else if (event.key === Qt.Key_Down + && selectRoot.currentIndex + 1 < selectRoot.options.length) { + selectRoot.selected(selectRoot.currentIndex + 1) + } else { + return + } + // Do not let Space reach the global play/pause binding. + event.accepted = true + } // `width`, NOT `menuWidth`: QbzSelect.slint:89-91 resolves the open list as // `max(popup-width, EFF-WIDTH)`, and eff-width is `menu-width - 40px` at @@ -88,10 +115,37 @@ Rectangle { if (!selectRoot.enabled || selectRoot.options.length === 0) return selectRoot.filter = "" + listContent.currentIndex = selectRoot.currentIndex popup.open() if (selectRoot.searchable) searchInput.forceActiveFocus() } + function optionMatches(i) { + return i >= 0 && i < options.length + && (filter === "" + || optLabel(i).toLowerCase().indexOf(filter.toLowerCase()) >= 0) + } + function moveSearchHighlight(delta) { + let i = listContent.currentIndex + for (let n = 0; n < options.length; n++) { + i += delta + if (i < 0 || i >= options.length) + break + if (optionMatches(i)) { + listContent.currentIndex = i + listContent.positionViewAtIndex(i, ListView.Contain) + return + } + } + } + function acceptSearchHighlight() { + const i = listContent.currentIndex + if (!optionMatches(i)) + return + popup.close() + selectRoot.forceActiveFocus() + selectRoot.selected(i) + } Row { anchors.fill: parent @@ -149,6 +203,7 @@ Rectangle { cursorShape: selectRoot.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { if (selectRoot.enabled) { + selectRoot.forceActiveFocus() selectRoot.openPopup() } } @@ -214,7 +269,30 @@ Rectangle { verticalAlignment: Text.AlignVCenter clip: true text: selectRoot.filter - onTextChanged: selectRoot.filter = text + onTextChanged: { + selectRoot.filter = text + if (!listContent) + return + for (let i = 0; i < selectRoot.options.length; i++) { + if (selectRoot.optionMatches(i)) { + listContent.currentIndex = i + return + } + } + listContent.currentIndex = -1 + } + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Down) + selectRoot.moveSearchHighlight(1) + else if (event.key === Qt.Key_Up) + selectRoot.moveSearchHighlight(-1) + else if (event.key === Qt.Key_Return + || event.key === Qt.Key_Enter) + selectRoot.acceptSearchHighlight() + else + return + event.accepted = true + } } Text { visible: searchInput.text === "" diff --git a/crates/qbz-qt/qml/controls/QbzSlider.qml b/crates/qbz-qt/qml/controls/QbzSlider.qml index 1a9018b3a..14d26da1f 100644 --- a/crates/qbz-qt/qml/controls/QbzSlider.qml +++ b/crates/qbz-qt/qml/controls/QbzSlider.qml @@ -8,6 +8,8 @@ import com.blitzfc.qbz import "../theme" Item { + id: root + property int minimum: 0 property int maximum: 10 property int value: 0 @@ -27,6 +29,17 @@ Item { // Disabled = inert (the bit-perfect ALSA direct path locks volume). // `enabled` already disarms the MouseArea; this is the matching dim. opacity: enabled ? 1.0 : 0.3 + activeFocusOnTab: enabled + Accessible.role: Accessible.Slider + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: theme.radiusSm + color: "transparent" + border.width: root.activeFocus ? 2 : 0 + border.color: theme.accent + } readonly property int thumbSize: 16 readonly property real travel: width - thumbSize @@ -42,6 +55,29 @@ Item { if (v !== value) changed(v) } + function keyboardCommit(nextValue) { + const v = Math.max(minimum, Math.min(maximum, nextValue)) + if (v !== value) + changed(v) + released(v) + } + + Keys.onPressed: function (event) { + if (!root.enabled || event.isAutoRepeat) + return + if (event.key === Qt.Key_Left || event.key === Qt.Key_Down) + root.keyboardCommit(root.value - 1) + else if (event.key === Qt.Key_Right || event.key === Qt.Key_Up) + root.keyboardCommit(root.value + 1) + else if (event.key === Qt.Key_Home) + root.keyboardCommit(root.minimum) + else if (event.key === Qt.Key_End) + root.keyboardCommit(root.maximum) + else + return + event.accepted = true + } + Rectangle { // track x: 0 y: Math.round((parent.height - height) / 2) @@ -70,19 +106,20 @@ Item { anchors.fill: parent cursorShape: Qt.PointingHandCursor onPressed: { - parent.dragging = true - parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) - parent.commit(parent.dragFraction) + root.forceActiveFocus() + root.dragging = true + root.dragFraction = Math.max(0, Math.min(1, (mouse.x - root.thumbSize / 2) / root.travel)) + root.commit(root.dragFraction) } onPositionChanged: { if (pressed) { - parent.dragFraction = Math.max(0, Math.min(1, (mouse.x - parent.thumbSize / 2) / parent.travel)) - parent.commit(parent.dragFraction) + root.dragFraction = Math.max(0, Math.min(1, (mouse.x - root.thumbSize / 2) / root.travel)) + root.commit(root.dragFraction) } } onReleased: { - parent.dragging = false - parent.released(parent.value) + root.dragging = false + root.released(root.value) } } } diff --git a/crates/qbz-qt/qml/controls/QbzToggle.qml b/crates/qbz-qt/qml/controls/QbzToggle.qml index 14dc825e2..48edebb42 100644 --- a/crates/qbz-qt/qml/controls/QbzToggle.qml +++ b/crates/qbz-qt/qml/controls/QbzToggle.qml @@ -8,6 +8,8 @@ import com.blitzfc.qbz import "../theme" Rectangle { + id: root + property bool checked: false property bool enabled: true signal toggled(bool value) @@ -19,6 +21,25 @@ Rectangle { radius: 11 color: checked ? theme.accent : theme.surfaceElevated opacity: enabled ? 1.0 : 0.4 + activeFocusOnTab: enabled + Accessible.role: Accessible.CheckBox + Accessible.checked: checked + border.width: activeFocus ? 2 : 0 + border.color: checked ? theme.accentGlyphColor : theme.accent + + // Space is the conventional toggle key. Accepting it here is + // load-bearing: it prevents the event from bubbling to AppShell's global + // play/pause binding while this control owns keyboard focus. + Keys.onPressed: function (event) { + if (root.enabled && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.toggled(!root.checked) + event.accepted = true + } + } + Accessible.onToggleAction: if (root.enabled) root.toggled(!root.checked) Rectangle { width: 16 @@ -32,6 +53,7 @@ Rectangle { MouseArea { anchors.fill: parent cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (parent.enabled) parent.toggled(!parent.checked) + onPressed: if (root.enabled) root.forceActiveFocus() + onClicked: if (root.enabled) root.toggled(!root.checked) } } diff --git a/crates/qbz-qt/qml/controls/SettingsButton.qml b/crates/qbz-qt/qml/controls/SettingsButton.qml index 6032e3be1..cead2eace 100644 --- a/crates/qbz-qt/qml/controls/SettingsButton.qml +++ b/crates/qbz-qt/qml/controls/SettingsButton.qml @@ -8,6 +8,8 @@ import com.blitzfc.qbz import "../theme" Rectangle { + id: root + property string text: "" property string iconName: "" // Trailing chevron/glyph AFTER the label (the Slint settings rows that open @@ -29,10 +31,25 @@ Rectangle { width: Math.max(iconName !== "" && text === "" ? 34 : minWidth, row.implicitWidth + 24) height: btnHeight radius: theme.radiusSm - border.width: 1 - border.color: danger ? theme.danger : theme.borderSubtle + border.width: root.activeFocus ? 2 : 1 + border.color: root.activeFocus ? theme.accent + : (danger ? theme.danger : theme.borderSubtle) color: enabled && btnArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated opacity: enabled ? 1.0 : 0.4 + activeFocusOnTab: enabled + Accessible.role: Accessible.Button + Accessible.name: text + Accessible.onPressAction: if (root.enabled) root.clicked() + + Keys.onPressed: function (event) { + if (root.enabled && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.clicked() + event.accepted = true + } + } Row { id: row @@ -68,6 +85,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: parent.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: if (parent.enabled) parent.clicked() + onPressed: if (root.enabled) root.forceActiveFocus() + onClicked: if (root.enabled) root.clicked() } } diff --git a/crates/qbz-qt/qml/settings/CustomThemeEditor.qml b/crates/qbz-qt/qml/settings/CustomThemeEditor.qml index 03484bddd..e345c6b8a 100644 --- a/crates/qbz-qt/qml/settings/CustomThemeEditor.qml +++ b/crates/qbz-qt/qml/settings/CustomThemeEditor.qml @@ -92,16 +92,35 @@ Column { spacing: 6 Rectangle { + id: swatch width: 56 height: 32 radius: theme.radiusSm - border.width: cell.open ? 2 : 1 - border.color: cell.open ? theme.accent : theme.borderSubtle + border.width: cell.open || swatch.activeFocus ? 2 : 1 + border.color: cell.open || swatch.activeFocus + ? theme.accent : theme.borderSubtle color: root.tokenColor(cell.tokenKey) + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: cell.label + Accessible.onPressAction: swatch.activate() + function activate() { + root.openToken = cell.open ? "" : cell.tokenKey + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + swatch.activate() + event.accepted = true + } + } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: root.openToken = cell.open ? "" : cell.tokenKey + onPressed: swatch.forceActiveFocus() + onClicked: swatch.activate() } } Text { diff --git a/crates/qbz-qt/qml/settings/DacWizardModal.qml b/crates/qbz-qt/qml/settings/DacWizardModal.qml index 755ffca23..1298fd452 100644 --- a/crates/qbz-qt/qml/settings/DacWizardModal.qml +++ b/crates/qbz-qt/qml/settings/DacWizardModal.qml @@ -69,6 +69,9 @@ Item { property bool showManual: false readonly property int openSeq: doc.openSeq || 0 + onVisibleChanged: if (visible) { + Qt.callLater(function () { closeX.forceActiveFocus() }) + } onOpenSeqChanged: { root.step = 0 root.welcomeConfirmed = false @@ -77,6 +80,13 @@ Item { root.reviewRestartDone = false root.showManual = false manualInput.text = "" + if (root.visible) + Qt.callLater(function () { closeX.forceActiveFocus() }) + } + + Keys.onEscapePressed: function (event) { + QbzDacWizard.close() + event.accepted = true } // Step labels for the header rail (index = step). @@ -116,12 +126,34 @@ Item { width: parent ? parent.width : 0 height: 28 + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.CheckBox + Accessible.name: label + Accessible.checked: checked + Accessible.onToggleAction: toggled() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + checkRow.toggled() + event.accepted = true + } + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "transparent" + border.width: checkRow.activeFocus ? 2 : 0 + border.color: theme.accent + } QbzCheckbox { id: box anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter checked: checkRow.checked + enabled: false // DISPLAY-ONLY. The row MouseArea below is declared last, so it // sits on top and owns every click — exactly as the reference // shadows QbzCheckbox's internal toggle. No `onToggled` here on @@ -142,6 +174,7 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor + onPressed: checkRow.forceActiveFocus() onClicked: checkRow.toggled() } } @@ -231,6 +264,26 @@ Item { anchors.verticalCenter: parent.verticalCenter width: 28 height: 28 + activeFocusOnTab: root.visible + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Close", QbzSession.trRev) + Accessible.onPressAction: QbzDacWizard.close() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + QbzDacWizard.close() + event.accepted = true + } + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "transparent" + border.width: closeX.activeFocus ? 2 : 0 + border.color: theme.accent + } QbzIcon { anchors.centerIn: parent width: 17 @@ -243,6 +296,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: closeX.forceActiveFocus() onClicked: QbzDacWizard.close() } } @@ -535,9 +589,33 @@ Item { // Escape-hatch toggle (only when enumeration found devices; // otherwise the manual block shows unconditionally). Item { + id: manualEntryToggle visible: root.doc.hasEnumeration === true width: parent.width height: visible ? 22 : 0 + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: manualToggle.text + Accessible.onPressAction: root.showManual = !root.showManual + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.showManual = !root.showManual + event.accepted = true + } + } + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + width: manualToggle.implicitWidth + height: parent.height + radius: theme.radiusSm + color: "transparent" + border.width: manualEntryToggle.activeFocus ? 2 : 0 + border.color: theme.accent + } Text { id: manualToggle anchors.verticalCenter: parent.verticalCenter @@ -553,6 +631,7 @@ Item { anchors.bottom: parent.bottom width: manualToggle.implicitWidth cursorShape: Qt.PointingHandCursor + onPressed: manualEntryToggle.forceActiveFocus() onClicked: root.showManual = !root.showManual } } @@ -667,12 +746,26 @@ Item { spacing: 8 Rectangle { + id: configHeader width: parent.width height: 40 radius: theme.radiusSm color: cfgHead.containsMouse ? theme.surfaceHover : theme.surfaceElevated border.width: 1 - border.color: theme.borderSubtle + border.color: activeFocus ? theme.accent : theme.borderSubtle + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: cfgCol.modelData.name || "" + Accessible.onPressAction: QbzDacWizard.toggleConfig(cfgCol.index) + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + QbzDacWizard.toggleConfig(cfgCol.index) + event.accepted = true + } + } QbzIcon { id: cfgChevron @@ -701,6 +794,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: configHeader.forceActiveFocus() onClicked: QbzDacWizard.toggleConfig(cfgCol.index) } } @@ -776,6 +870,24 @@ Item { radius: theme.radiusSm color: (trackTa.containsMouse && testStep.playing) ? theme.surfaceHover : "transparent" + activeFocusOnTab: testStep.playing + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: trackRow.modelData + Accessible.onPressAction: { + if (testStep.playing) + QbzDacWizard.testPlayIndex(trackRow.index) + } + Keys.onPressed: function (event) { + if (testStep.playing && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + QbzDacWizard.testPlayIndex(trackRow.index) + event.accepted = true + } + } // Fixed glyph slot (width AND height) so the // ♪→▶ hover swap can never reflow the row. @@ -809,6 +921,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: testStep.playing ? Qt.PointingHandCursor : Qt.ArrowCursor + onPressed: if (testStep.playing) trackRow.forceActiveFocus() onClicked: { if (testStep.playing) QbzDacWizard.testPlayIndex(trackRow.index) @@ -841,12 +954,26 @@ Item { // playing (the 4 test tracks or the user's own), which // is why they call the PLAYER, not the wizard. Rectangle { + id: prevButton visible: testStep.playing width: visible ? 34 : 0 height: 34 radius: theme.radiusSm border.width: 1 - border.color: theme.borderSubtle + border.color: activeFocus ? theme.accent : theme.borderSubtle + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Previous", QbzSession.trRev) + Accessible.onPressAction: QbzPlayer.previous() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + QbzPlayer.previous() + event.accepted = true + } + } color: prevTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent @@ -860,16 +987,31 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: prevButton.forceActiveFocus() onClicked: QbzPlayer.previous() } } Rectangle { + id: nextButton visible: testStep.playing width: visible ? 34 : 0 height: 34 radius: theme.radiusSm border.width: 1 - border.color: theme.borderSubtle + border.color: activeFocus ? theme.accent : theme.borderSubtle + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Next", QbzSession.trRev) + Accessible.onPressAction: QbzPlayer.next() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + QbzPlayer.next() + event.accepted = true + } + } color: nextTa.containsMouse ? theme.surfaceHover : theme.surfaceElevated QbzIcon { anchors.centerIn: parent @@ -883,6 +1025,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: nextButton.forceActiveFocus() onClicked: QbzPlayer.next() } } diff --git a/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml b/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml index a03086077..7dae42f23 100644 --- a/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml +++ b/crates/qbz-qt/qml/settings/DiagnosticsPanel.qml @@ -120,8 +120,29 @@ Column { visible: (section.rows || []).length > 0 Item { + id: sectionHeader width: parent.width height: 28 + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: section.title + Accessible.onPressAction: section.open = !section.open + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + section.open = !section.open + event.accepted = true + } + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "transparent" + border.width: sectionHeader.activeFocus ? 2 : 0 + border.color: theme.accent + } Text { anchors.verticalCenter: parent.verticalCenter text: section.title @@ -140,6 +161,7 @@ Column { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor + onPressed: sectionHeader.forceActiveFocus() onClicked: section.open = !section.open } } @@ -180,8 +202,35 @@ Column { // ------------------------------------------------------------- header Item { + id: panelHeader width: parent.width height: 44 + activeFocusOnTab: visible && enabled + Accessible.role: Accessible.Button + Accessible.name: root.t("Diagnostics") + Accessible.onPressAction: panelHeader.activate() + function activate() { + root.expanded = !root.expanded + QbzShell.diagSetOpen(root.expanded) + if (root.expanded && root.doc.loaded !== true && root.doc.loading !== true) + QbzShell.diagRefresh() + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + panelHeader.activate() + event.accepted = true + } + } + Rectangle { + anchors.fill: parent + radius: theme.radiusSm + color: "transparent" + border.width: panelHeader.activeFocus ? 2 : 0 + border.color: theme.accent + } Column { anchors.verticalCenter: parent.verticalCenter spacing: 2 @@ -208,16 +257,8 @@ Column { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: { - root.expanded = !root.expanded - // Gates the publisher: a collapsed panel is not reading, and a - // publish can carry a hundred rows. - QbzShell.diagSetOpen(root.expanded) - // First expand collects; afterwards Refresh is explicit, 1:1 - // with DiagnosticsPanel.slint:201-203. - if (root.expanded && root.doc.loaded !== true && root.doc.loading !== true) - QbzShell.diagRefresh() - } + onPressed: panelHeader.forceActiveFocus() + onClicked: panelHeader.activate() } } diff --git a/crates/qbz-qt/qml/settings/ImportExportSettings.qml b/crates/qbz-qt/qml/settings/ImportExportSettings.qml index 20eff10e6..bfebca603 100644 --- a/crates/qbz-qt/qml/settings/ImportExportSettings.qml +++ b/crates/qbz-qt/qml/settings/ImportExportSettings.qml @@ -20,6 +20,8 @@ Column { id: root property var doc: ({}) + /// View-level migration setup surface. Kept nullable for isolated previews. + property var migrationSetupModal: null readonly property var ie: doc.importExport || ({}) QbzTheme { id: theme } @@ -129,74 +131,13 @@ Column { SettingRow { label: QbzSession.tr("Migrate into this account", QbzSession.trRev) description: (root.ie.snapshots || []).length > 0 - ? QbzSession.tr("Sign in with the account you are moving TO, then pick a snapshot below. Safe to repeat: a second run adds nothing.", QbzSession.trRev) + ? QbzSession.tr("Review the saved source profiles, choose what to copy, and confirm the signed-in destination account.", QbzSession.trRev) : QbzSession.tr("No snapshots yet. Sign in with the old account and create one first.", QbzSession.trRev) - } - // What the local half carries besides library, playlist folders, pinned - // items, blacklist and preferences. Asked every time, all on by - // default (owner decision 2026-09-02); session state, not persisted. - property bool copyLocalProfile: true - property bool copyMediaServers: true - property bool copyScrobblers: true - property bool copyListeningHistory: true - SettingRow { - label: QbzSession.tr("Also copy the local profile", QbzSession.trRev) - description: QbzSession.tr("Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer.", QbzSession.trRev) - QbzToggle { - checked: root.copyLocalProfile - onToggled: function (v) { root.copyLocalProfile = v } - } - } - SettingRow { - rowEnabled: root.copyLocalProfile - label: QbzSession.tr("Media server connections", QbzSession.trRev) - description: QbzSession.tr("Plex, Jellyfin and Subsonic settings, including their credentials.", QbzSession.trRev) - QbzToggle { - checked: root.copyMediaServers - onToggled: function (v) { root.copyMediaServers = v } - } - } - SettingRow { - rowEnabled: root.copyLocalProfile - label: QbzSession.tr("Scrobbler accounts", QbzSession.trRev) - description: QbzSession.tr("Last.fm and ListenBrainz settings, including their credentials.", QbzSession.trRev) - QbzToggle { - checked: root.copyScrobblers - onToggled: function (v) { root.copyScrobblers = v } - } - } - SettingRow { - rowEnabled: root.copyLocalProfile - label: QbzSession.tr("Listening history", QbzSession.trRev) - description: QbzSession.tr("The listen log and the events behind offline recommendations.", QbzSession.trRev) - QbzToggle { - checked: root.copyListeningHistory - onToggled: function (v) { root.copyListeningHistory = v } - } - } - Repeater { - model: root.ie.snapshots || [] - delegate: SettingRow { - required property var modelData - label: modelData.label - description: QbzSession.tr("{} favorites · {} playlists · {} followed", QbzSession.trRev) - .replace("{}", modelData.favorites) - .replace("{}", modelData.playlists) - .replace("{}", modelData.subscriptions) - + (modelData.isCurrentAccount - ? " · " + QbzSession.tr("this account", QbzSession.trRev) - : "") - SettingsButton { - text: QbzSession.tr("Migrate…", QbzSession.trRev) - enabled: !root.ie.migrationBusy && !modelData.isCurrentAccount - onClicked: QbzBridge.settingsString("account-migrate", JSON.stringify({ - path: modelData.path, - local_profile: root.copyLocalProfile, - media_servers: root.copyMediaServers, - scrobblers: root.copyScrobblers, - listening_history: root.copyListeningHistory - })) - } + SettingsButton { + text: QbzSession.tr("Migrate…", QbzSession.trRev) + enabled: !root.ie.migrationBusy + && (root.ie.snapshots || []).length > 0 + onClicked: if (root.migrationSetupModal) root.migrationSetupModal.open() } } Text { diff --git a/crates/qbz-qt/qml/settings/IntegrationsSettings.qml b/crates/qbz-qt/qml/settings/IntegrationsSettings.qml index a4623be9f..658c7401b 100644 --- a/crates/qbz-qt/qml/settings/IntegrationsSettings.qml +++ b/crates/qbz-qt/qml/settings/IntegrationsSettings.qml @@ -134,11 +134,31 @@ Column { spacing: 8 // Collapse chevron (only when the master toggle is on). Rectangle { + id: collapseButton visible: root.doc.scrobbleEnabled === true width: 28 height: 28 radius: theme.radiusSm color: colArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: visible && enabled + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Scrobbling", QbzSession.trRev) + Accessible.onPressAction: collapseButton.activate() + function activate() { + QbzBridge.settingsBool( + "scrobble-collapse", root.doc.scrobbleUiCollapsed !== true) + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + collapseButton.activate() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent // 28px hit-box / 18px glyph (IntegrationsSettings.slint:129). @@ -152,7 +172,8 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: QbzBridge.settingsBool("scrobble-collapse", root.doc.scrobbleUiCollapsed !== true) + onPressed: collapseButton.forceActiveFocus() + onClicked: collapseButton.activate() } } QbzToggle { diff --git a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml index 1c951fc35..65b9382ac 100644 --- a/crates/qbz-qt/qml/settings/LibFolderEditModal.qml +++ b/crates/qbz-qt/qml/settings/LibFolderEditModal.qml @@ -63,7 +63,7 @@ Item { root.draftOverride = root.st.userOverrideNetwork === true root.draftFsIndex = root.st.fsTypeIndex || 0 aliasField.text = root.draftAlias - keyScope.forceActiveFocus() + closeButton.forceActiveFocus() } function close() { @@ -81,12 +81,9 @@ Item { })) } - // Esc dismiss. No visual and no MouseArea, so it never intercepts a press - // meant for the scrim (the QbzConfirmModal idiom). - FocusScope { - id: keyScope - anchors.fill: parent - Keys.onEscapePressed: root.close() + Keys.onEscapePressed: function (event) { + root.close() + event.accepted = true } // Scrim — click dismisses. @@ -149,12 +146,28 @@ Item { font.weight: theme.weightSemibold } Rectangle { + id: closeButton anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 28 height: 28 radius: theme.radiusSm color: closeArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: root.visible + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Close", QbzSession.trRev) + Accessible.onPressAction: root.close() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.close() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent name: "x" @@ -167,6 +180,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: closeButton.forceActiveFocus() onClicked: root.close() } } @@ -422,11 +436,27 @@ Item { // this same way (:218-232), including the // `accentGlyphColor` selector for the label. Rectangle { + id: saveButton anchors.verticalCenter: parent.verticalCenter width: saveLabel.implicitWidth + 36 height: 36 radius: theme.radiusSm color: saveArea.containsMouse ? theme.accentHover : theme.accent + activeFocusOnTab: root.visible + border.width: activeFocus ? 2 : 0 + border.color: theme.accentGlyphColor + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Save", QbzSession.trRev) + Accessible.onPressAction: root.save() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.save() + event.accepted = true + } + } Text { id: saveLabel anchors.centerIn: parent @@ -440,6 +470,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: saveButton.forceActiveFocus() onClicked: root.save() } } diff --git a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml index e629b8a36..7997b3a4c 100644 --- a/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml +++ b/crates/qbz-qt/qml/settings/LocalTabsConfigModal.qml @@ -44,6 +44,7 @@ Item { function open() { root.draftOrder = root.storedOrder() root._open = true + Qt.callLater(function () { closeButton.forceActiveFocus() }) } function close() { root._open = false } @@ -74,6 +75,10 @@ Item { visible: root._open enabled: root._open z: 3100 + Keys.onEscapePressed: function (event) { + root.close() + event.accepted = true + } Rectangle { anchors.fill: parent @@ -122,12 +127,28 @@ Item { font.weight: theme.weightSemibold } Rectangle { + id: closeButton anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 28 height: 28 radius: 6 color: closeArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: root.opened + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Close", QbzSession.trRev) + Accessible.onPressAction: root.close() + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + root.close() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent name: "x" @@ -140,6 +161,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: closeButton.forceActiveFocus() onClicked: root.close() } } @@ -234,12 +256,30 @@ Item { } Rectangle { + id: resetButton width: resetRow.width + 24 height: 34 radius: theme.radiusSm color: resetArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated border.width: 1 - border.color: theme.borderSubtle + border.color: activeFocus ? theme.accent : theme.borderSubtle + activeFocusOnTab: root.opened + Accessible.role: Accessible.Button + Accessible.name: QbzSession.tr("Reset to defaults", QbzSession.trRev) + Accessible.onPressAction: resetButton.activate() + function activate() { + root.draftOrder = root.defaultOrder.slice() + QbzBridge.settingsString("local-tab-order-reset", "") + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + resetButton.activate() + event.accepted = true + } + } Row { id: resetRow anchors.centerIn: parent @@ -263,10 +303,8 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { - root.draftOrder = root.defaultOrder.slice() - QbzBridge.settingsString("local-tab-order-reset", "") - } + onPressed: resetButton.forceActiveFocus() + onClicked: resetButton.activate() } } } @@ -284,6 +322,20 @@ Item { opacity: reorderRoot.buttonEnabled ? 1.0 : 0.3 color: reorderRoot.buttonEnabled && reorderArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: reorderRoot.buttonEnabled + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.onPressAction: if (reorderRoot.buttonEnabled) reorderRoot.clicked() + Keys.onPressed: function (event) { + if (reorderRoot.buttonEnabled && !event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + reorderRoot.clicked() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent @@ -298,6 +350,7 @@ Item { enabled: reorderRoot.buttonEnabled hoverEnabled: reorderRoot.buttonEnabled cursorShape: reorderRoot.buttonEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onPressed: reorderRoot.forceActiveFocus() onClicked: reorderRoot.clicked() } } diff --git a/crates/qbz-qt/qml/settings/MediaServerSettings.qml b/crates/qbz-qt/qml/settings/MediaServerSettings.qml index 986c5f3d1..e5bdc5f8e 100644 --- a/crates/qbz-qt/qml/settings/MediaServerSettings.qml +++ b/crates/qbz-qt/qml/settings/MediaServerSettings.qml @@ -131,12 +131,32 @@ Column { anchors.verticalCenter: parent.verticalCenter spacing: 8 Rectangle { + id: collapseButton visible: root.state.enabled === true width: 28 height: 28 radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter color: chevArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: visible && enabled + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: root.title + Accessible.onPressAction: collapseButton.activate() + function activate() { + QbzBridge.settingsBool( + root.server + "-collapse", root.state.collapsed !== true) + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + collapseButton.activate() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent name: root.state.collapsed === true ? "chevron-right" : "chevron-down" @@ -149,8 +169,8 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: QbzBridge.settingsBool( - root.server + "-collapse", root.state.collapsed !== true) + onPressed: collapseButton.forceActiveFocus() + onClicked: collapseButton.activate() } } QbzToggle { diff --git a/crates/qbz-qt/qml/settings/MigrationProgressModal.qml b/crates/qbz-qt/qml/settings/MigrationProgressModal.qml new file mode 100644 index 000000000..3ef1d0b0c --- /dev/null +++ b/crates/qbz-qt/qml/settings/MigrationProgressModal.qml @@ -0,0 +1,347 @@ +// Full-view account-migration progress. The operation belongs to Rust and +// keeps running if this panel is dismissed; this overlay only makes the +// long-lived phases, account direction and elapsed time explicit. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + property var doc: ({}) + readonly property var ie: doc.importExport || ({}) + readonly property bool running: ie.migrationRunning === true + readonly property bool succeeded: ie.migrationSucceeded === true + readonly property int currentStep: Math.max(1, ie.migrationStep || 1) + readonly property int stepTotal: Math.max(1, ie.migrationStepTotal || 6) + readonly property int itemDone: Math.max(0, ie.migrationProgressDone || 0) + readonly property int itemTotal: Math.max(0, ie.migrationProgressTotal || 0) + readonly property real itemProgress: itemTotal > 0 + ? Math.min(1, itemDone / itemTotal) : (running ? 0.08 : 0) + readonly property real overallProgress: (!running && succeeded) ? 1 + : Math.max(0, Math.min(1, + ((currentStep - 1) + itemProgress) / stepTotal)) + + property bool openedForRun: false + property bool dismissed: true + property int elapsedSeconds: 0 + + QbzTheme { id: theme } + + function beginRun() { + root.openedForRun = true + root.dismissed = false + root.elapsedSeconds = 0 + Qt.callLater(function () { closeButton.forceActiveFocus() }) + } + function close() { + root.dismissed = true + root.restoreShellFocus() + } + function restoreShellFocus() { + var item = root + while (item.parent) { + if (item.parent.isQbzShellRoot === true) { + item.parent.forceActiveFocus() + return + } + item = item.parent + } + } + function elapsedLabel() { + const minutes = Math.floor(root.elapsedSeconds / 60) + const seconds = root.elapsedSeconds % 60 + return minutes + ":" + (seconds < 10 ? "0" : "") + seconds + } + function stepLabel(step) { + switch (step) { + case 1: return QbzSession.tr("Reading this account…", QbzSession.trRev) + case 2: return QbzSession.tr("Adding favorites…", QbzSession.trRev) + case 3: return QbzSession.tr("Adding playlists…", QbzSession.trRev) + case 4: return QbzSession.tr("Following playlists…", QbzSession.trRev) + case 5: return QbzSession.tr("Copying local profile…", QbzSession.trRev) + default: return QbzSession.tr("Refreshing migrated data…", QbzSession.trRev) + } + } + + onRunningChanged: if (running) beginRun() + Component.onCompleted: if (running) beginRun() + + Timer { + interval: 1000 + repeat: true + running: root.running + onTriggered: root.elapsedSeconds += 1 + } + + visible: openedForRun && !dismissed + enabled: visible + z: 3200 + + Keys.onEscapePressed: function (event) { + root.close() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root.close() + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 620) + height: Math.min(parent.height * 0.9, 680) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Item { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 68 + + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.right: headerSpinner.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("ACCOUNT MIGRATION", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontSection + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + QbzSpinner { + id: headerSpinner + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + size: 20 + visible: root.running + spinning: root.running + } + } + Rectangle { + id: headerDivider + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + Item { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 70 + + SettingsButton { + id: closeButton + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Close", QbzSession.trRev) + onClicked: root.close() + } + } + Rectangle { + id: footerDivider + anchors.bottom: footer.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + Flickable { + anchors.top: headerDivider.bottom + anchors.bottom: footerDivider.top + anchors.left: parent.left + anchors.right: parent.right + clip: true + contentWidth: width + contentHeight: body.implicitHeight + 48 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: body + x: 24 + y: 24 + width: parent.width - 48 + spacing: 16 + + WarningBanner { + width: parent.width + variant: "warning" + title: QbzSession.tr("This only happens once. Please wait for it to finish and do not close the window.", QbzSession.trRev) + } + + Text { + width: parent.width + text: QbzSession.tr("You can close this panel; the migration will continue in the background.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontLegal + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: identityCol.implicitHeight + 24 + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + + Column { + id: identityCol + x: 12 + y: 12 + width: parent.width - 24 + spacing: 8 + + Text { + width: parent.width + text: QbzSession.tr("From", QbzSession.trRev) + + ": " + (root.ie.migrationSource || "—") + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: QbzSession.tr("To", QbzSession.trRev) + + ": " + (root.ie.migrationTarget || "—") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + } + } + + Column { + width: parent.width + spacing: 8 + + Item { + width: parent.width + height: 20 + Text { + anchors.left: parent.left + anchors.right: elapsed.right + anchors.rightMargin: 12 + text: QbzSession.tr("Step {} of {}", QbzSession.trRev) + .replace("{}", root.currentStep) + .replace("{}", root.stepTotal) + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + } + Text { + id: elapsed + anchors.right: parent.right + text: QbzSession.tr("Elapsed: {}", QbzSession.trRev) + .replace("{}", root.elapsedLabel()) + color: theme.textMuted + font.pixelSize: theme.fontLegal + } + } + Rectangle { + width: parent.width + height: 10 + radius: 5 + color: theme.borderSubtle + clip: true + Rectangle { + width: parent.width * root.overallProgress + height: parent.height + radius: 5 + color: theme.accent + } + } + Text { + width: parent.width + text: root.ie.migrationStatus || root.stepLabel(root.currentStep) + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + } + + Column { + width: parent.width + spacing: 6 + + Repeater { + model: root.stepTotal + delegate: Item { + required property int index + readonly property int number: index + 1 + readonly property bool complete: number < root.currentStep + || (!root.running && root.succeeded) + readonly property bool active: number === root.currentStep + && root.running + width: parent ? parent.width : 0 + height: 30 + + Rectangle { + id: stepDot + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + radius: 12 + color: parent.complete || parent.active + ? theme.accent : "transparent" + border.width: parent.complete || parent.active ? 0 : 1 + border.color: theme.borderSubtle + + Text { + anchors.centerIn: parent + text: stepDot.parent.complete ? "✓" : stepDot.parent.number + color: stepDot.parent.complete || stepDot.parent.active + ? theme.onAccent : theme.textMuted + font.pixelSize: theme.fontLegal + font.weight: theme.weightSemibold + } + } + Text { + anchors.left: stepDot.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.stepLabel(parent.number) + color: parent.active ? theme.textPrimary : theme.textSecondary + font.pixelSize: theme.fontBody + font.weight: parent.active + ? theme.weightSemibold : theme.weightRegular + elide: Text.ElideRight + } + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/MigrationSetupModal.qml b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml new file mode 100644 index 000000000..987c717a6 --- /dev/null +++ b/crates/qbz-qt/qml/settings/MigrationSetupModal.qml @@ -0,0 +1,367 @@ +// Account-migration preparation surface. Selection and copy options live here +// rather than in the Settings page so the user reviews one explicit +// source -> authenticated destination before the long-running operation. + +import QtQuick +import com.blitzfc.qbz +import "../controls" +import "../theme" + +Item { + id: root + + property var doc: ({}) + property var confirmHost: null + readonly property var ie: doc.importExport || ({}) + readonly property var profiles: ie.snapshots || [] + readonly property var selectedProfile: { + for (let i = 0; i < profiles.length; i++) { + if (profiles[i].path === selectedPath) + return profiles[i] + } + return null + } + + property bool opened: false + property string selectedPath: "" + property bool copyLocalProfile: true + property bool copyMediaServers: true + property bool copyScrobblers: true + property bool copyListeningHistory: true + + QbzTheme { id: theme } + + function open() { + root.selectedPath = "" + root.copyLocalProfile = true + root.copyMediaServers = true + root.copyScrobblers = true + root.copyListeningHistory = true + root.opened = true + Qt.callLater(function () { closeButton.forceActiveFocus() }) + } + function close() { + root.opened = false + root.restoreShellFocus() + } + function restoreShellFocus() { + var item = root + while (item.parent) { + if (item.parent.isQbzShellRoot === true) { + item.parent.forceActiveFocus() + return + } + item = item.parent + } + } + function askDelete(path) { + if (!root.confirmHost) + return + root.confirmHost.ask( + QbzSession.tr("Delete migration snapshot?", QbzSession.trRev), + QbzSession.tr("This removes only the migration bundle. The old account's QBZ profile and user data are kept.", QbzSession.trRev), + QbzSession.tr("Delete snapshot", QbzSession.trRev), + function () { + if (root.selectedPath === path) + root.selectedPath = "" + QbzBridge.settingsString("account-delete-snapshot", path) + }) + } + function startMigration() { + if (!root.selectedProfile || root.ie.migrationBusy) + return + QbzBridge.settingsString("account-migrate", JSON.stringify({ + path: root.selectedProfile.path, + local_profile: root.copyLocalProfile, + media_servers: root.copyMediaServers, + scrobblers: root.copyScrobblers, + listening_history: root.copyListeningHistory + })) + root.close() + } + + visible: root.opened + enabled: root.opened + z: 3050 + + Keys.onEscapePressed: function (event) { + root.close() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: theme.radiusMd + color: "#bf000000" + MouseArea { + anchors.fill: parent + onClicked: root.close() + onWheel: function (wheel) { wheel.accepted = true } + } + } + + Rectangle { + id: card + anchors.centerIn: parent + width: Math.min(parent.width - 80, 760) + height: Math.min(parent.height - 48, 780) + radius: theme.radiusLg + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Item { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 68 + Text { + anchors.left: parent.left + anchors.leftMargin: 24 + anchors.verticalCenter: parent.verticalCenter + text: QbzSession.tr("Migrate account data", QbzSession.trRev) + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + } + } + Rectangle { + id: headerDivider + anchors.top: header.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + Item { + id: footer + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 70 + Row { + anchors.right: parent.right + anchors.rightMargin: 24 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + SettingsButton { + id: closeButton + minWidth: 100 + text: QbzSession.tr("Cancel", QbzSession.trRev) + onClicked: root.close() + } + SettingsButton { + minWidth: 160 + text: QbzSession.tr("Start migration", QbzSession.trRev) + enabled: root.selectedProfile !== null && !root.ie.migrationBusy + onClicked: root.startMigration() + } + } + } + Rectangle { + id: footerDivider + anchors.bottom: footer.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: theme.borderSubtle + } + + Flickable { + id: flick + anchors.top: headerDivider.bottom + anchors.bottom: footerDivider.top + anchors.left: parent.left + anchors.right: parent.right + clip: true + contentWidth: width + contentHeight: body.implicitHeight + 48 + boundsBehavior: Flickable.StopAtBounds + + Column { + id: body + x: 24 + y: 24 + width: parent.width - 48 + spacing: 12 + + WarningBanner { + width: parent.width + variant: "info" + title: QbzSession.tr("What is a migration profile?", QbzSession.trRev) + body: QbzSession.tr("It is a one-time bundle created from the source account. It preserves that account's Qobuz favorites and playlists so they can be added to the account signed in now. Optional local data is copied directly from the old QBZ profile on this computer. Deleting the bundle does not delete the old profile or either account's data.", QbzSession.trRev) + } + + GroupHeader { text: QbzSession.tr("SAVED SOURCE PROFILES", QbzSession.trRev) } + + Text { + visible: root.profiles.length === 0 + width: parent.width + text: QbzSession.tr("No saved migration profiles are available.", QbzSession.trRev) + color: theme.textMuted + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + + Repeater { + model: root.profiles + delegate: Rectangle { + required property var modelData + readonly property bool selected: + root.selectedPath === modelData.path + width: body.width + height: 92 + radius: theme.radiusMd + color: selected ? theme.surfaceHover : theme.surfaceElevated + border.width: selected ? 2 : 1 + border.color: selected ? theme.accent : theme.borderSubtle + + Column { + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: actions.left + anchors.rightMargin: 16 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + Text { + width: parent.width + text: modelData.label + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightMedium + elide: Text.ElideRight + } + Text { + width: parent.width + text: QbzSession.tr("{} favorites · {} playlists · {} followed", QbzSession.trRev) + .replace("{}", modelData.favorites) + .replace("{}", modelData.playlists) + .replace("{}", modelData.subscriptions) + + (modelData.isCurrentAccount + ? " · " + QbzSession.tr("this account", QbzSession.trRev) + : "") + color: theme.textMuted + font.pixelSize: theme.fontLegal + elide: Text.ElideRight + } + } + Row { + id: actions + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + SettingsButton { + minWidth: 92 + text: parent.parent.selected + ? QbzSession.tr("Selected", QbzSession.trRev) + : QbzSession.tr("Select", QbzSession.trRev) + enabled: !modelData.isCurrentAccount + && !root.ie.migrationBusy + onClicked: root.selectedPath = modelData.path + } + SettingsButton { + minWidth: 92 + text: QbzSession.tr("Delete…", QbzSession.trRev) + danger: true + enabled: !root.ie.migrationBusy + onClicked: root.askDelete(modelData.path) + } + } + } + } + + GroupHeader { text: QbzSession.tr("MIGRATION DIRECTION", QbzSession.trRev) } + Rectangle { + width: parent.width + height: direction.implicitHeight + 24 + radius: theme.radiusMd + color: theme.surfaceElevated + border.width: 1 + border.color: theme.borderSubtle + Column { + id: direction + x: 12 + y: 12 + width: parent.width - 24 + spacing: 8 + Text { + width: parent.width + text: QbzSession.tr("From", QbzSession.trRev) + ": " + + (root.selectedProfile + ? root.selectedProfile.sourceIdentity + : QbzSession.tr("Select a source profile above", QbzSession.trRev)) + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.WordWrap + } + Text { + width: parent.width + text: QbzSession.tr("To", QbzSession.trRev) + ": " + + QbzSession.tr("Signed-in Qobuz account · ID {}", QbzSession.trRev) + .replace("{}", root.ie.currentUserId || "—") + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + wrapMode: Text.WordWrap + } + } + } + + GroupHeader { text: QbzSession.tr("WHAT TO COPY", QbzSession.trRev) } + SettingRow { + label: QbzSession.tr("Also copy the local profile", QbzSession.trRev) + description: QbzSession.tr("Library folders, playlist folders and order, pinned items, blacklist and preferences of the old account's QBZ profile on this computer.", QbzSession.trRev) + QbzToggle { + checked: root.copyLocalProfile + onToggled: function (value) { root.copyLocalProfile = value } + } + } + SettingRow { + rowEnabled: root.copyLocalProfile + label: QbzSession.tr("Media server connections", QbzSession.trRev) + description: QbzSession.tr("Plex, Jellyfin and Subsonic settings, including their credentials.", QbzSession.trRev) + QbzToggle { + checked: root.copyMediaServers + enabled: root.copyLocalProfile + onToggled: function (value) { root.copyMediaServers = value } + } + } + SettingRow { + rowEnabled: root.copyLocalProfile + label: QbzSession.tr("Scrobbler accounts", QbzSession.trRev) + description: QbzSession.tr("Last.fm and ListenBrainz settings, including their credentials.", QbzSession.trRev) + QbzToggle { + checked: root.copyScrobblers + enabled: root.copyLocalProfile + onToggled: function (value) { root.copyScrobblers = value } + } + } + SettingRow { + rowEnabled: root.copyLocalProfile + label: QbzSession.tr("Listening history", QbzSession.trRev) + description: QbzSession.tr("The listen log and the events behind offline recommendations.", QbzSession.trRev) + QbzToggle { + checked: root.copyListeningHistory + enabled: root.copyLocalProfile + onToggled: function (value) { root.copyListeningHistory = value } + } + } + + WarningBanner { + width: parent.width + variant: "warning" + title: QbzSession.tr("This only happens once. Please wait for it to finish and do not close the window.", QbzSession.trRev) + body: QbzSession.tr("Migration is additive: it does not remove favorites or playlists from either account.", QbzSession.trRev) + } + } + } + } +} diff --git a/crates/qbz-qt/qml/settings/PlexSettings.qml b/crates/qbz-qt/qml/settings/PlexSettings.qml index ae630b48c..d2a870e1e 100644 --- a/crates/qbz-qt/qml/settings/PlexSettings.qml +++ b/crates/qbz-qt/qml/settings/PlexSettings.qml @@ -133,12 +133,32 @@ Column { anchors.verticalCenter: parent.verticalCenter spacing: 8 Rectangle { + id: collapseButton visible: QbzLocal.plexEnabled width: 28 height: 28 radius: theme.radiusSm anchors.verticalCenter: parent.verticalCenter color: chevArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: visible && enabled + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: "Plex" + Accessible.onPressAction: collapseButton.activate() + function activate() { + QbzBridge.settingsBool( + "plex-collapse", root.plex.collapsed !== true) + } + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + collapseButton.activate() + event.accepted = true + } + } QbzIcon { anchors.centerIn: parent name: root.plex.collapsed === true ? "chevron-right" : "chevron-down" @@ -151,7 +171,8 @@ Column { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: QbzBridge.settingsBool("plex-collapse", root.plex.collapsed !== true) + onPressed: collapseButton.forceActiveFocus() + onClicked: collapseButton.activate() } } QbzToggle { diff --git a/crates/qbz-qt/qml/settings/SettingsView.qml b/crates/qbz-qt/qml/settings/SettingsView.qml index 4225e8dc0..7fe65b43c 100644 --- a/crates/qbz-qt/qml/settings/SettingsView.qml +++ b/crates/qbz-qt/qml/settings/SettingsView.qml @@ -24,6 +24,18 @@ // republishes, and the rows re-render from the new document (the Slint // "single source of truth in SettingsState" pattern). // +// KEYBOARD FOCUS CONTRACT (for every new setting): Qt Quick has no HTML-like +// numeric tabindex; its tab index is the declaration order of enabled, +// visible items with `activeFocusOnTab`. Therefore settings and their controls +// MUST be declared in the same top-to-bottom order in which they are drawn and +// MUST use the shared QbzToggle/QbzSelect/QbzSlider/QbzLineEdit/SettingsButton/ +// QbzCheckbox controls. Do not put a bare MouseArea on a new setting action: +// the shared controls provide the focus ring and Enter/Space activation, and +// accept Space so it cannot bubble into AppShell's global play/pause hotkey. +// If a custom interactive item is genuinely necessary, it inherits this same +// contract. This comment is intentionally at the composition root so both +// contributors and coding agents encounter it when adding a settings row. +// // The NavButtons row inside the 92px header is a 0px placeholder — nav // history lives in the global HeaderBar in this port, like every other view. @@ -53,6 +65,8 @@ Item { // `root.section = n` would work once and then re-introduce the bug one // navigation later, in a way that looks fixed under casual testing. readonly property int section: QbzBridge.settingsSection + readonly property bool migrationRunning: + (doc.importExport || ({})).migrationRunning === true readonly property bool sandboxed: { const m = (doc.dev || ({})).installMethod || "" @@ -74,8 +88,12 @@ Item { // ============================ the view ================================ Column { + id: settingsContent anchors.fill: parent spacing: 0 + // A migration may be hidden, but its settings tree remains inert until + // Rust finishes. Global navigation is outside this view and stays live. + enabled: !root.migrationRunning // --- Header (92px; NavButtons is a 0px placeholder in this port) -- Item { @@ -114,6 +132,22 @@ Item { radius: theme.radiusSm color: active ? theme.surfaceElevated : snArea.containsMouse ? theme.surfaceHover : "transparent" + activeFocusOnTab: visible && enabled + border.width: activeFocus ? 2 : 0 + border.color: theme.accent + Accessible.role: Accessible.Button + Accessible.name: label + Accessible.onPressAction: clicked() + + Keys.onPressed: function (event) { + if (!event.isAutoRepeat + && (event.key === Qt.Key_Space + || event.key === Qt.Key_Return + || event.key === Qt.Key_Enter)) { + clicked() + event.accepted = true + } + } Row { anchors.fill: parent anchors.leftMargin: 12 @@ -144,6 +178,7 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onPressed: parent.forceActiveFocus() onClicked: parent.clicked() } } @@ -260,6 +295,37 @@ Item { clip: true boundsBehavior: Flickable.StopAtBounds + // Tab can move into a control below the viewport. Reveal + // that control as focus changes so keyboard navigation is + // never technically correct but visually lost offscreen. + function revealFocusItem(item) { + if (!item) + return + var ancestor = item + while (ancestor && ancestor !== panelCol) + ancestor = ancestor.parent + if (ancestor !== panelCol) + return + const point = item.mapToItem(flick.contentItem, 0, 0) + const margin = 12 + if (point.y < flick.contentY + margin) + flick.contentY = Math.max(0, point.y - margin) + else if (point.y + item.height > flick.contentY + flick.height - margin) + flick.contentY = Math.min( + Math.max(0, flick.contentHeight - flick.height), + point.y + item.height - flick.height + margin) + } + + Connections { + target: root.Window.window + function onActiveFocusItemChanged() { + const win = root.Window.window + Qt.callLater(function () { + flick.revealFocusItem(win ? win.activeFocusItem : null) + }) + } + } + Column { id: panelCol x: 20 @@ -332,7 +398,7 @@ Item { OfflineSettings { width: parent.width doc: root.doc - confirmHost: confirmHost + confirmHost: settingsConfirmHost } } } @@ -342,7 +408,7 @@ Item { LocalLibrarySettings { width: parent.width doc: root.doc - confirmHost: confirmHost + confirmHost: settingsConfirmHost tabOrderModal: localTabsConfigModal } } @@ -362,7 +428,7 @@ Item { IntegrationsSettings { width: parent.width doc: root.doc - confirmHost: confirmHost + confirmHost: settingsConfirmHost } } } @@ -372,6 +438,7 @@ Item { ImportExportSettings { width: parent.width doc: root.doc + migrationSetupModal: migrationSetupOverlay } } } @@ -416,7 +483,7 @@ Item { // declaration order alone puts it over every panel and the sub-nav. // Panels that need it are handed the reference (see LocalLibrarySettings // / PlexSettings `confirmHost`). - SettingsConfirmHost { id: confirmHost } + SettingsConfirmHost { id: settingsConfirmHost } // App-wide Local Library order. Mounted at the view root so its scrim // covers the sub-navigation and the scrolled panel alike. @@ -440,4 +507,18 @@ Item { // area, and the reference is an overlay inside the app shell too, not a // separate window (DacWizardModal.slint:8-9). DacWizardModal { } + + MigrationSetupModal { + id: migrationSetupOverlay + anchors.fill: parent + doc: root.doc + confirmHost: settingsConfirmHost + } + + // Mounted last so a long account migration has one unmistakable progress + // surface over every Settings section. Closing it never cancels the task. + MigrationProgressModal { + anchors.fill: parent + doc: root.doc + } } diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 471b59c2a..cd541d1bf 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -149,8 +149,9 @@ Rectangle { // Escape stack) lives in Rust behind QbzHotkeys.keyPressed; the QML side // only computes the gate (§1.4.4). Null activeFocusItem passes the gate // (the semantically right case). Every existing Keys. handler accepts - // only what it owns, and all buttons are MouseArea-based and can never - // take keyboard focus — verified non-conflicts (§4.1). + // only what it owns. Shared controls accept their Space/Enter events + // before they bubble here; in particular a focused Settings toggle owns + // Space, so toggling it cannot also trigger global play/pause (§4.1). Keys.onPressed: function (event) { var w = root.Window.window var afi = w !== null ? w.activeFocusItem : null diff --git a/crates/qbz-qt/src/local_bridge.rs b/crates/qbz-qt/src/local_bridge.rs index 862444b6f..fd9a6810d 100644 --- a/crates/qbz-qt/src/local_bridge.rs +++ b/crates/qbz-qt/src/local_bridge.rs @@ -2090,7 +2090,9 @@ fn media_kind(server: &QString) -> Option crate::media_sync_qt::sync_jellyfin(full).await, + MediaServerKind::Jellyfin => crate::media_sync_qt::sync_jellyfin(full) + .await + .map_err(crate::media_sync_qt::SyncError::Failed), MediaServerKind::Subsonic => crate::media_sync_qt::sync_subsonic(full).await, }; match result { @@ -2107,6 +2109,12 @@ async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind, crate::settings_qt::publish_snapshot().await; reload_browse(); } - Err(e) => crate::toast_qt::error(e), + Err(crate::media_sync_qt::SyncError::ServerRefreshing { server, scanned }) => { + crate::toast_qt::info(qbz_i18n::t_args( + "{} is refreshing its library ({} items scanned). Wait for it to finish, then sync again.", + &[&server, &scanned.to_string()], + )); + } + Err(crate::media_sync_qt::SyncError::Failed(error)) => crate::toast_qt::error(error), } } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index d60761668..a75ac55c5 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -3507,6 +3507,25 @@ fn apply_scroll_physics() { } } +/// Apply Settings > Appearance > Interface size through Qt's native high-DPI +/// path. This must run before `QGuiApplication::new()`: Qt then lays out the +/// scene in device-independent pixels and rasterizes text/vector content at +/// the effective DPR. A QML `scale` transform instead enlarges an already +/// rendered scene and produces the blurred text this setting exists to avoid. +fn apply_interface_scale_preference() { + const QT_SCALE_FACTOR: &str = "QT_SCALE_FACTOR"; + let explicit = std::env::var_os(QT_SCALE_FACTOR) + .map(|value| !value.to_string_lossy().trim().is_empty()) + .unwrap_or(false); + if explicit { + log::info!("[qbz-qt] explicit {QT_SCALE_FACTOR} preserved"); + return; + } + let factor = settings_qt::ui_scale_factor(); + std::env::set_var(QT_SCALE_FACTOR, factor.to_string()); + log::info!("[qbz-qt] native interface scale -> {factor} (QT_SCALE_FACTOR)"); +} + // ============================ Shutdown guarantee ========================== /// One-shot latch for [`arm_hard_exit_watchdog`]. @@ -3572,6 +3591,9 @@ pub(crate) fn arm_hard_exit_watchdog(source: &'static str) { fn main() { qbz_log::install("info"); + // Before even the disposable GPU-preflight QGuiApplication. A child + // spawned later inherits the resolved factor and takes the same path. + apply_interface_scale_preference(); // Internal, disposable graphics child. It must branch before the normal // instance lock, navigation crash-chain, runtime and audio thread: a GPU // whose DMA-BUFs the active Wayland compositor rejects is expected to kill diff --git a/crates/qbz-qt/src/media_sync_qt.rs b/crates/qbz-qt/src/media_sync_qt.rs index 6d60dfdf3..d796288da 100644 --- a/crates/qbz-qt/src/media_sync_qt.rs +++ b/crates/qbz-qt/src/media_sync_qt.rs @@ -226,6 +226,30 @@ pub struct SyncReport { pub total: u64, } +/// A media-cache sweep failure that the UI can present at the right severity. +/// A server-side library refresh is not a broken connection: Navidrome keeps +/// answering requests while its own catalog is incomplete, and exposes that +/// state through `getScanStatus`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SyncError { + Failed(String), + ServerRefreshing { server: String, scanned: u64 }, +} + +impl std::fmt::Display for SyncError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Failed(message) => f.write_str(message), + Self::ServerRefreshing { server, scanned } => { + write!( + f, + "{server} is refreshing its library ({scanned} items scanned)" + ) + } + } + } +} + /// Push a progress line to the UI. Cheap enough to call once per bounded page. fn report(kind: MediaServerKind, done: u64, total: u64) { log::info!("[qbz-qt] {} sync: {done}/{total}", kind.as_str()); @@ -740,31 +764,66 @@ fn iso8601(unix_secs: i64) -> String { /// behaviour most likely to differ on a server that was never on the bench, so /// a probe decides and the portable `getAlbumList2` + `getAlbum` walk is the /// fallback. -pub async fn sync_subsonic(_full: bool) -> Result { +pub async fn sync_subsonic(_full: bool) -> Result { let kind = MediaServerKind::Subsonic; let Some(_guard) = BusyGuard::acquire(kind) else { - return Err("a subsonic sync is already running".into()); + return Err(SyncError::Failed( + "a subsonic sync is already running".into(), + )); }; let cfg = crate::media_servers_qt::get(kind); let Some((base, creds)) = crate::media_servers_qt::subsonic_credentials() else { - return Err("subsonic is not configured".into()); + return Err(SyncError::Failed("subsonic is not configured".into())); }; + let client = qbz_subsonic::SubsonicClient::new(&base, creds) + .map_err(|error| SyncError::Failed(error.to_string()))?; + // This is the SERVER'S scan, not QBZ's cache sweep. Navidrome may keep + // answering search/list calls with an incomplete catalog while it runs; + // importing that intermediate view would look like a broken integration + // and could authorize a misleading empty generation. The endpoint has no + // total, only a processed count, so the UI reports exactly that. + match client.scan_status().await { + Ok(status) if status.scanning => { + let raw_server = cfg + .server_name + .split_whitespace() + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Subsonic"); + let server = match raw_server.to_ascii_lowercase().as_str() { + "navidrome" => "Navidrome".to_string(), + "gonic" => "Gonic".to_string(), + "airsonic" => "Airsonic".to_string(), + _ => raw_server.to_string(), + }; + return Err(SyncError::ServerRefreshing { + server, + scanned: status.count, + }); + } + Ok(_) => {} + Err(error) => { + // Some older/partial Subsonic servers omit this standard endpoint. + // That cannot disable an otherwise working integration; continue + // with QBZ's guarded generation and retain its failure semantics. + log::warn!("[media-sync] source=subsonic server scan status unavailable: {error}"); + } + } let sync_epoch = SUBSONIC_EPOCH.fetch_add(1, Ordering::AcqRel) + 1; set_syncing_ui(true); - let out = sync_subsonic_inner(cfg, base, creds, sync_epoch).await; + let out = sync_subsonic_inner(cfg, client, sync_epoch) + .await + .map_err(SyncError::Failed); set_syncing_ui(false); out } async fn sync_subsonic_inner( cfg: MediaServerSettings, - base: String, - creds: qbz_subsonic::Credentials, + client: qbz_subsonic::SubsonicClient, sync_epoch: u64, ) -> Result { let kind = MediaServerKind::Subsonic; - let client = qbz_subsonic::SubsonicClient::new(&base, creds).map_err(|e| e.to_string())?; - let folders = client.music_folders().await.map_err(|e| e.to_string())?; if !source_epoch_is_current(RemoteSource::Subsonic, sync_epoch) { return Err("subsonic sync cancelled".to_string()); diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 3048d26c8..6df24f518 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1003,9 +1003,8 @@ pub fn show_context_icon() -> bool { // main.rs:1399-1435). SAME shared file, SAME types: the sizes are JSON floats // holding LOGICAL (device-independent) pixels, the flag is a bool. Both // frontends read each other's numbers, so a type or unit change here silently -// corrupts the Slint profile — and "unit" includes the interface-size preset, -// which Slint bakes into its scale factor and this frontend does not -// (`ui_scale_factor_for` converts; identity under the default preset). +// corrupts the shared profile — and "unit" includes the interface-size +// preset. Both frontends now bake it into their native high-DPI scale. // // Why this exists at all: Main.qml used to hardcode 1280x800 and never save, // so the Qt build opened one responsive tier below the Slint build on the same @@ -1026,42 +1025,19 @@ pub fn show_context_icon() -> bool { /// `min-width: 940px / UiScale.factor` and the restore clamp repeats it at /// `main.rs:8214-8215`. /// -/// It stays a FLAT 940 here because the divisor is mirrored in the unit -/// conversion instead (see `ui_scale_factor_for`), not in the gate: the Qt -/// POC applies no interface-size preset, so its logical pixel IS the -/// preset-free one, and 940 preset-free pixels is exactly what Slint's -/// `940 / factor` scaled-logical minimum comes out to. +/// The constants are the preset-free physical floor. [`window_min_size`] +/// converts them to Qt's scaled logical coordinate system at startup. pub const WINDOW_MIN_WIDTH: f32 = 940.0; pub const WINDOW_MIN_HEIGHT: f32 = 600.0; /// The interface-size preset factor for a persisted `ui_scale` slug — the SAME /// table as Slint's `ui_prefs::ui_scale_factor` (ui_prefs.rs:243-251). /// -/// Why this matters for geometry: Slint bakes the preset into its scale factor -/// (`SLINT_SCALE_FACTOR = last_dpr * factor`, main.rs:8354-8362), so the -/// `window_width` it stores is `physical / (dpr * factor)`. Qt applies no -/// preset, so ITS logical pixel is `physical / dpr`. The two numbers are the -/// same window only after multiplying by the factor: -/// -/// qt_logical = slint_logical * factor -/// -/// which is why the restore multiplies and the save divides. Under the default -/// preset the factor is exactly 1.0 and both are identities — nothing about -/// the owner's current profile changes. -/// -/// Why NOT the alternative (refuse to write while the preset is non-default): -/// it fixes the corruption but leaves geometry restore silently dead for every -/// non-default user, and it would have to keep refusing forever, whereas the -/// conversion is exact. Its one assumption is that both frontends see the same -/// display DPR — the same assumption Slint's own `last_dpr` bake-in makes, and -/// wrong only for a window dragged between mismatched monitors between runs, -/// where the WM clamp catches it anyway. -/// -/// Mirroring only the GATES (940 / factor on both sides, no conversion) would -/// be wrong here: a value Slint saved at 752 scaled-logical would then pass and -/// be applied as a 752-pixel Qt window — below the app's real minimum, because -/// Qt's content does not shrink with the preset the way the `.slint` bindings -/// do. +/// Qt receives this factor before `QGuiApplication` is constructed, through +/// `QT_SCALE_FACTOR`. It therefore uses the same scaled-logical coordinate +/// system as the persisted geometry: text, vector graphics and controls are +/// laid out and rasterized at the effective DPR instead of enlarging a +/// finished scene texture. fn ui_scale_factor_for(slug: &str) -> f32 { match slug { "xs" => 0.8, @@ -1072,6 +1048,62 @@ fn ui_scale_factor_for(slug: &str) -> f32 { } } +/// The persisted interface-size multiplier consumed before Qt starts. +pub fn ui_scale_factor() -> f32 { + ui_scale_factor_for(&pref_str("ui_scale", "default")) +} + +/// The factor Qt is actually using. Normally `main` writes the persisted +/// preset into `QT_SCALE_FACTOR`, so this equals [`ui_scale_factor`]. Keeping +/// the explicit-environment case exact matters for developer DPI tests: the +/// shared geometry must not be rewritten in the override's units. +fn qt_effective_scale_factor() -> f32 { + std::env::var("QT_SCALE_FACTOR") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or_else(ui_scale_factor) +} + +/// The app floor in Qt's native high-DPI logical coordinates. Multiplying the +/// result by the interface factor returns the same 940x600 baseline on every +/// preset, matching the reference frontend's `min-width / UiScale.factor`. +pub fn window_min_size() -> (f32, f32) { + let factor = qt_effective_scale_factor(); + (WINDOW_MIN_WIDTH / factor, WINDOW_MIN_HEIGHT / factor) +} + +/// Change the scale slug while keeping the persisted native window footprint +/// stable. Stored geometry is expressed in Slint scaled-logical units, so a +/// slug change without this rebase would make the next launch multiply the old +/// units by the new factor and unexpectedly resize the window. +fn set_ui_scale_in_doc(doc: &mut serde_json::Map, slug: &str) -> bool { + let old_slug = doc + .get("ui_scale") + .and_then(serde_json::Value::as_str) + .unwrap_or("default"); + if old_slug == slug { + return false; + } + let ratio = ui_scale_factor_for(old_slug) as f64 / ui_scale_factor_for(slug) as f64; + for key in ["window_width", "window_height"] { + if let Some(value) = doc.get(key).and_then(serde_json::Value::as_f64) { + if value.is_finite() && value > 0.0 { + doc.insert(key.to_string(), serde_json::Value::from(value * ratio)); + } + } + } + doc.insert( + "ui_scale".to_string(), + serde_json::Value::String(slug.to_string()), + ); + true +} + +fn save_ui_scale(slug: &str) { + update_prefs(|doc| set_ui_scale_in_doc(doc, slug)); +} + /// The never-saved size. 1180x760 is both `app.slint:47-48`'s preferred size /// (what Slint falls back to by doing nothing) and the literal Slint plugs in /// at `main.rs:8223-8224` when it has to size the window itself. One number @@ -1079,9 +1111,9 @@ fn ui_scale_factor_for(slug: &str) -> f32 { pub const WINDOW_DEFAULT_WIDTH: f32 = 1180.0; pub const WINDOW_DEFAULT_HEIGHT: f32 = 760.0; -/// Restored floating size in QT logical pixels, one file read for both axes -/// AND the preset they were written under (`ui_scale_factor_for`) — one -/// document backs all three, so a preset change mid-read cannot mix units. +/// Restored floating size in Qt high-DPI logical pixels. Qt and the persisted +/// document now use the same interface-size-aware coordinate system, so no +/// second conversion is allowed here. /// /// The all-or-nothing gate is Slint's `has_saved_size` (main.rs:8220-8221): /// BOTH axes must clear the minimum or the pair counts as never saved. A @@ -1118,7 +1150,8 @@ pub fn window_size() -> (f32, f32) { let floor_w = (WINDOW_MIN_WIDTH / factor).min(WINDOW_MIN_WIDTH); let floor_h = (WINDOW_MIN_HEIGHT / factor).min(WINDOW_MIN_HEIGHT); if stored_w >= floor_w && stored_h >= floor_h { - (stored_w * factor, stored_h * factor) + let qt_factor = qt_effective_scale_factor(); + (stored_w * factor / qt_factor, stored_h * factor / qt_factor) } else { (WINDOW_DEFAULT_WIDTH, WINDOW_DEFAULT_HEIGHT) } @@ -1159,19 +1192,17 @@ pub fn save_window_geometry(width: f32, height: f32, maximized: bool, fullscreen return; } update_prefs(|doc| { - // The preset comes out of the SAME document the sizes go into, so the - // stored value and the unit it is stored in can never disagree. - // `width`/`height` arrive in QT logical pixels; the file speaks SLINT - // scaled-logical (see `ui_scale_factor_for`), so divide on the way in - // exactly as `window_size` multiplies on the way out. Identity under - // the default preset. + // Qt normally receives this same persisted factor before + // QGuiApplication starts. An explicit QT_SCALE_FACTOR is allowed for + // diagnostics, so convert between the two only in that override case. let factor = ui_scale_factor_for( doc.get("ui_scale") .and_then(|v| v.as_str()) .unwrap_or("default"), ); - let stored_width = width / factor; - let stored_height = height / factor; + let qt_factor = qt_effective_scale_factor(); + let stored_width = width * qt_factor / factor; + let stored_height = height * qt_factor / factor; // Read the three previous values first — the dirty comparison decides // whether the document is touched at all, and it compares stored @@ -1189,12 +1220,11 @@ pub fn save_window_geometry(width: f32, height: f32, maximized: bool, fullscreen .and_then(serde_json::Value::as_f64) .unwrap_or(0.0) as f32; - // The minimum is checked in QT pixels (the frame we were actually - // handed); `WINDOW_MIN_*` is already the preset-free floor. + // The minimum is checked in Qt's scaled logical pixels. let size_dirty = !maximized && !fullscreen - && width >= WINDOW_MIN_WIDTH - && height >= WINDOW_MIN_HEIGHT + && width >= WINDOW_MIN_WIDTH / qt_factor + && height >= WINDOW_MIN_HEIGHT / qt_factor && ((prev_width - stored_width).abs() > 0.5 || (prev_height - stored_height).abs() > 0.5); if !size_dirty && was_maximized == maximized { @@ -3964,8 +3994,8 @@ pub async fn settings_select(runtime: &Arc>, key: &st let Some(scale) = UI_SCALE_VALUES.get(index) else { return; }; - save_pref("ui_scale", serde_json::json!(scale)); - log::info!("[qbz-qt] ui_scale -> {scale} (restart to apply)"); + save_ui_scale(scale); + log::info!("[qbz-qt] ui_scale -> {scale} (applies at next start)"); } "immersive-search-action" => { let Some(v) = IMMERSIVE_SEARCH_VALUES.get(index) else { @@ -4259,6 +4289,7 @@ pub async fn settings_string(key: &str, value: String) { "export-blacklist" => import_export::export_blacklist().await, "import-blacklist" => import_export::import_blacklist().await, "account-snapshot" => import_export::create_snapshot().await, + "account-delete-snapshot" => import_export::delete_snapshot(value).await, "account-migrate" => import_export::migrate(value).await, other => log::warn!("[qbz-qt] unknown settings string key: {other}"), } @@ -4357,6 +4388,33 @@ mod local_tab_order_tests { ); } + #[test] + fn changing_interface_scale_preserves_the_native_window_footprint() { + let mut doc = serde_json::json!({ + "ui_scale": "small", + "window_width": 1200.0, + "window_height": 700.0 + }) + .as_object() + .unwrap() + .clone(); + let native_before = ( + doc["window_width"].as_f64().unwrap() * ui_scale_factor_for("small") as f64, + doc["window_height"].as_f64().unwrap() * ui_scale_factor_for("small") as f64, + ); + + assert!(set_ui_scale_in_doc(&mut doc, "xl")); + + let native_after = ( + doc["window_width"].as_f64().unwrap() * ui_scale_factor_for("xl") as f64, + doc["window_height"].as_f64().unwrap() * ui_scale_factor_for("xl") as f64, + ); + assert!((native_before.0 - native_after.0).abs() < 0.001); + assert!((native_before.1 - native_after.1).abs() < 0.001); + assert_eq!(doc["ui_scale"].as_str(), Some("xl")); + assert!(!set_ui_scale_in_doc(&mut doc, "xl")); + } + #[test] fn stored_order_deduplicates_filters_and_appends_missing_tabs() { let value = serde_json::json!(["tracks", "future-tab", "tracks", "albums", 7, "artists"]); diff --git a/crates/qbz-qt/src/settings_qt/import_export.rs b/crates/qbz-qt/src/settings_qt/import_export.rs index cf57d20d9..d7070b0aa 100644 --- a/crates/qbz-qt/src/settings_qt/import_export.rs +++ b/crates/qbz-qt/src/settings_qt/import_export.rs @@ -24,11 +24,22 @@ static SETTINGS_STATUS: LazyLock> = LazyLock::new(|| Mutex::new(St static BLACKLIST_STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); static SNAPSHOT_STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); static MIGRATION_STATUS: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); +static MIGRATION_SOURCE: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); +static MIGRATION_TARGET: LazyLock> = LazyLock::new(|| Mutex::new(String::new())); /// One snapshot or migration at a time; the buttons disable on it. static MIGRATION_BUSY: AtomicBool = AtomicBool::new(false); +/// Structured state for the full-view progress modal. `MIGRATION_BUSY` also +/// covers snapshot creation/deletion; this flag is intentionally migrate-only. +static MIGRATION_RUNNING: AtomicBool = AtomicBool::new(false); +static MIGRATION_SUCCEEDED: AtomicBool = AtomicBool::new(false); +static MIGRATION_STEP: AtomicUsize = AtomicUsize::new(0); +static MIGRATION_PROGRESS_DONE: AtomicUsize = AtomicUsize::new(0); +static MIGRATION_PROGRESS_TOTAL: AtomicUsize = AtomicUsize::new(0); /// Progress events since the last republish (throttle). static PROGRESS_TICKS: AtomicUsize = AtomicUsize::new(0); +const MIGRATION_STEP_TOTAL: usize = 6; + fn set_settings_status(text: String) { *SETTINGS_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = text; } @@ -45,6 +56,13 @@ fn set_migration_status(text: String) { *MIGRATION_STATUS.lock().unwrap_or_else(|e| e.into_inner()) = text; } +fn set_migration_step(step: usize, text: String) { + MIGRATION_STEP.store(step, Ordering::Relaxed); + MIGRATION_PROGRESS_DONE.store(0, Ordering::Relaxed); + MIGRATION_PROGRESS_TOTAL.store(0, Ordering::Relaxed); + set_migration_status(text); +} + fn read(status: &Mutex) -> String { status.lock().unwrap_or_else(|e| e.into_inner()).clone() } @@ -55,8 +73,10 @@ fn read(status: &Mutex) -> String { pub struct SnapshotRow { pub user_id: u64, pub path: String, - /// "Account 10385965 · 2026-09-02 01:20" + /// Identity + capture time, for the settings row. pub label: String, + /// Identity without the capture time, for the From → To progress panel. + pub source_identity: String, pub favorites: usize, pub playlists: usize, pub subscriptions: usize, @@ -76,6 +96,17 @@ pub struct Snapshot { pub snapshot_status: String, pub migration_status: String, pub migration_busy: bool, + pub migration_running: bool, + pub migration_succeeded: bool, + pub migration_source: String, + pub migration_target: String, + pub migration_step: usize, + pub migration_step_total: usize, + pub migration_progress_done: usize, + pub migration_progress_total: usize, + /// Explicit destination shown before a migration starts. The source rows + /// carry name/email; the runtime always has the authenticated numeric ID. + pub current_user_id: u64, pub snapshots: Vec, } @@ -89,10 +120,31 @@ pub fn snapshot() -> Snapshot { snapshot_status: read(&SNAPSHOT_STATUS), migration_status: read(&MIGRATION_STATUS), migration_busy: MIGRATION_BUSY.load(Ordering::Relaxed), + migration_running: MIGRATION_RUNNING.load(Ordering::Relaxed), + migration_succeeded: MIGRATION_SUCCEEDED.load(Ordering::Relaxed), + migration_source: read(&MIGRATION_SOURCE), + migration_target: read(&MIGRATION_TARGET), + migration_step: MIGRATION_STEP.load(Ordering::Relaxed), + migration_step_total: MIGRATION_STEP_TOTAL, + migration_progress_done: MIGRATION_PROGRESS_DONE.load(Ordering::Relaxed), + migration_progress_total: MIGRATION_PROGRESS_TOTAL.load(Ordering::Relaxed), + current_user_id: crate::app().active_user_id().unwrap_or(0), snapshots: snapshot_rows(), } } +fn account_identity(display_name: &str, email: &str, user_id: u64) -> String { + let mut parts = Vec::with_capacity(3); + if !display_name.trim().is_empty() { + parts.push(display_name.trim().to_string()); + } + if !email.trim().is_empty() { + parts.push(email.trim().to_string()); + } + parts.push(format!("ID {user_id}")); + parts.join(" · ") +} + fn snapshot_rows() -> Vec { let Ok(root) = UserDataPaths::global_data_dir() else { return Vec::new(); @@ -103,15 +155,16 @@ fn snapshot_rows() -> Vec { .filter_map(|(user_id, path)| { let snap = migration::AccountSnapshot::read(&path).ok()?; let when = snap.created_at_local(); - let who = if snap.source.display_name.is_empty() { - user_id.to_string() - } else { - format!("{} ({})", snap.source.display_name, user_id) - }; + let identity = account_identity( + &snap.source.display_name, + &snap.source.email, + snap.source.user_id, + ); Some(SnapshotRow { user_id, path: path.to_string_lossy().into_owned(), - label: qbz_i18n::t_args("Account {} · {}", &[&who, &when]), + label: format!("{identity} · {when}"), + source_identity: identity, favorites: snap.favorites.total(), playlists: snap.playlists.len(), subscriptions: snap.subscriptions.len(), @@ -249,23 +302,50 @@ pub async fn import_blacklist() { /// Progress sink: phase changes and every 10th item update the status /// line and republish the document, so a 2 000-favorite run shows life /// without republishing 2 000 times. -fn progress_sink(status: &'static Mutex) -> impl Fn(MigrationEvent) + Send + Sync { +fn progress_sink( + status: &'static Mutex, + migration_modal: bool, +) -> impl Fn(MigrationEvent) + Send + Sync { move |event| { let (text, publish) = match event { - MigrationEvent::Phase(phase) => ( - match phase { - MigrationPhase::ReadingSource => { - qbz_i18n::t("Reading favorites and playlists…") - } - MigrationPhase::ReadingTarget => qbz_i18n::t("Reading this account…"), - MigrationPhase::Favorites => qbz_i18n::t("Adding favorites…"), - MigrationPhase::Playlists => qbz_i18n::t("Adding playlists…"), - MigrationPhase::Subscriptions => qbz_i18n::t("Following playlists…"), - MigrationPhase::Done => String::new(), - }, - true, - ), + MigrationEvent::Phase(phase) => { + if migration_modal { + let step = match phase { + // `capture` calls this ReadingSource even when its + // result is the live TARGET used to build the delta. + MigrationPhase::ReadingSource | MigrationPhase::ReadingTarget => 1, + MigrationPhase::Favorites => 2, + MigrationPhase::Playlists => 3, + MigrationPhase::Subscriptions => 4, + MigrationPhase::Done => 4, + }; + MIGRATION_STEP.store(step, Ordering::Relaxed); + MIGRATION_PROGRESS_DONE.store(0, Ordering::Relaxed); + MIGRATION_PROGRESS_TOTAL.store(0, Ordering::Relaxed); + } + ( + match phase { + MigrationPhase::ReadingSource => { + if migration_modal { + qbz_i18n::t("Reading this account…") + } else { + qbz_i18n::t("Reading favorites and playlists…") + } + } + MigrationPhase::ReadingTarget => qbz_i18n::t("Reading this account…"), + MigrationPhase::Favorites => qbz_i18n::t("Adding favorites…"), + MigrationPhase::Playlists => qbz_i18n::t("Adding playlists…"), + MigrationPhase::Subscriptions => qbz_i18n::t("Following playlists…"), + MigrationPhase::Done => String::new(), + }, + true, + ) + } MigrationEvent::Progress { done, total, label } => { + if migration_modal { + MIGRATION_PROGRESS_DONE.store((done + 1).min(total), Ordering::Relaxed); + MIGRATION_PROGRESS_TOTAL.store(total, Ordering::Relaxed); + } let n = PROGRESS_TICKS.fetch_add(1, Ordering::Relaxed); ( qbz_i18n::t_args( @@ -312,13 +392,19 @@ pub async fn create_snapshot() { let result: Result = async { let (client, session) = signed_in_client().await?; let profile_dir = UserDataPaths::data_dir_for(session.user_id)?; - let sink = progress_sink(&SNAPSHOT_STATUS); - let snap = migration::capture(&client, session.user_id, &session.display_name, &sink) - .await - .map_err(|e| { - log::error!("[qbz-qt] account snapshot failed: {e}"); - qbz_i18n::t("Snapshot failed.") - })?; + let sink = progress_sink(&SNAPSHOT_STATUS, false); + let snap = migration::capture( + &client, + session.user_id, + &session.display_name, + &session.email, + &sink, + ) + .await + .map_err(|e| { + log::error!("[qbz-qt] account snapshot failed: {e}"); + qbz_i18n::t("Snapshot failed.") + })?; let path = migration::snapshot::snapshot_path(&profile_dir); snap.write(&path).map_err(|e| { log::error!("[qbz-qt] account snapshot write failed: {e}"); @@ -341,6 +427,32 @@ pub async fn create_snapshot() { MIGRATION_BUSY.store(false, Ordering::SeqCst); } +/// Delete one migration bundle after the settings-level confirmation. The +/// core validates that the path is one of the bundles it would list, so a +/// crafted bridge call cannot delete arbitrary files from the profile. +pub async fn delete_snapshot(path: String) { + if MIGRATION_BUSY.swap(true, Ordering::SeqCst) { + return; + } + let result = async { + let root = UserDataPaths::global_data_dir()?; + tokio::task::spawn_blocking(move || { + migration::snapshot::delete_snapshot(&root, std::path::Path::new(&path)) + }) + .await + .map_err(|e| e.to_string())? + } + .await; + match result { + Ok(()) => set_snapshot_status(qbz_i18n::t("Migration snapshot deleted.")), + Err(e) => { + log::error!("[qbz-qt] migration snapshot delete failed: {e}"); + set_snapshot_status(qbz_i18n::t("Could not delete the migration snapshot.")); + } + } + MIGRATION_BUSY.store(false, Ordering::SeqCst); +} + /// The panel's "Migrate…" payload: the snapshot path plus the local-copy /// options. A bare path (older panel) means everything on. #[derive(Debug, serde::Deserialize)] @@ -375,7 +487,9 @@ impl MigrateRequest { /// "Migrate…" on a snapshot row: plan against this account's live state /// and apply, additively; then copy the old profile's local data through /// the ledger's playlist map. Re-warms the favorites cache and reloads the -/// sidebar; the local half needs a restart to be picked up by every store. +/// sidebar. Server and scrobbler stores are rebound immediately after their +/// databases are copied so an already-open Settings view cannot keep showing +/// the target profile's stale pre-migration cache. pub async fn migrate(value: String) { if MIGRATION_BUSY.swap(true, Ordering::SeqCst) { return; @@ -383,6 +497,7 @@ pub async fn migrate(value: String) { let request = MigrateRequest::parse(&value); let path = request.path.clone(); set_migration_status(qbz_i18n::t("Reading this account…")); + MIGRATION_SUCCEEDED.store(false, Ordering::Relaxed); let runtime = crate::app(); let result: Result = async { let source = migration::AccountSnapshot::read(std::path::Path::new(&path)).map_err(|e| { @@ -395,60 +510,88 @@ pub async fn migrate(value: String) { "That snapshot was taken from this same account.", )); } + *MIGRATION_SOURCE.lock().unwrap_or_else(|e| e.into_inner()) = account_identity( + &source.source.display_name, + &source.source.email, + source.source.user_id, + ); + *MIGRATION_TARGET.lock().unwrap_or_else(|e| e.into_inner()) = + account_identity(&session.display_name, &session.email, session.user_id); + PROGRESS_TICKS.store(0, Ordering::Relaxed); + MIGRATION_RUNNING.store(true, Ordering::SeqCst); + set_migration_step(1, qbz_i18n::t("Reading this account…")); + super::publish_snapshot().await; let profile_dir = UserDataPaths::data_dir_for(session.user_id)?; - let sink = progress_sink(&MIGRATION_STATUS); - sink(MigrationEvent::Phase(MigrationPhase::ReadingTarget)); - let target = migration::capture(&client, session.user_id, &session.display_name, &sink) - .await - .map_err(|e| { - log::error!("[qbz-qt] target capture failed: {e}"); - qbz_i18n::t("Migration failed.") - })?; + let sink = progress_sink(&MIGRATION_STATUS, true); + let target = migration::capture( + &client, + session.user_id, + &session.display_name, + &session.email, + &sink, + ) + .await + .map_err(|e| { + log::error!("[qbz-qt] target capture failed: {e}"); + qbz_i18n::t("Migration failed.") + })?; let ledger = migration::Ledger::load(&profile_dir).map_err(|e| { log::error!("[qbz-qt] ledger unreadable: {e}"); qbz_i18n::t("Migration failed.") })?; let plan = migration::plan(&source, &target, &ledger); - if plan.is_empty() { - return Ok(qbz_i18n::t( + let mut text = if plan.is_empty() { + qbz_i18n::t( "Nothing to migrate: everything is already in this account.", - )); - } - let report = migration::apply(&plan, &client, &profile_dir, source.source.user_id, &sink) + ) + } else { + let report = migration::apply( + &plan, + &client, + &profile_dir, + source.source.user_id, + &sink, + ) .await .map_err(|e| { log::error!("[qbz-qt] migration apply failed: {e}"); qbz_i18n::t("Migration failed.") })?; - let mut text = qbz_i18n::t_args( - "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there.", - &[ - &report.favorites.added.to_string(), - &report.playlists.added.to_string(), - &report.tracks_added.to_string(), - &report.subscriptions.added.to_string(), - &(report.favorites.already + report.playlists.already + report.subscriptions.already) - .to_string(), - ], - ); - if report.failed() > 0 { - for line in report - .favorites - .failed - .iter() - .chain(&report.playlists.failed) - .chain(&report.subscriptions.failed) - { - log::warn!("[qbz-qt] migration item failed: {line}"); + let mut cloud_text = qbz_i18n::t_args( + "Added {} favorites, {} playlists ({} tracks) and {} followed playlists; {} were already there.", + &[ + &report.favorites.added.to_string(), + &report.playlists.added.to_string(), + &report.tracks_added.to_string(), + &report.subscriptions.added.to_string(), + &(report.favorites.already + + report.playlists.already + + report.subscriptions.already) + .to_string(), + ], + ); + if report.failed() > 0 { + for line in report + .favorites + .failed + .iter() + .chain(&report.playlists.failed) + .chain(&report.subscriptions.failed) + { + log::warn!("[qbz-qt] migration item failed: {line}"); + } + cloud_text.push(' '); + cloud_text.push_str(&qbz_i18n::t_args( + "{} items failed; see the log.", + &[&report.failed().to_string()], + )); } - text.push(' '); - text.push_str(&qbz_i18n::t_args( - "{} items failed; see the log.", - &[&report.failed().to_string()], - )); - } + cloud_text + }; // --- Local profile (old users// → this one) ----------------- if request.local_profile { + set_migration_step(5, qbz_i18n::t("Copying local profile…")); + super::publish_snapshot().await; let src_dir = UserDataPaths::data_dir_for(source.source.user_id)?; if src_dir.is_dir() { let ledger = migration::Ledger::load(&profile_dir).map_err(|e| { @@ -479,12 +622,46 @@ pub async fn migrate(value: String) { &local.unmapped_playlist_rows.to_string(), ], )); + if request.media_servers { + text.push(' '); + if local.media_connections_found == 0 { + text.push_str(&qbz_i18n::t( + "No Plex, Jellyfin or Subsonic connection was stored in the source profile, so none could be copied.", + )); + } else { + text.push_str(&qbz_i18n::t_args( + "Media server connections copied: {} of {}.", + &[ + &local.media_connections_copied.to_string(), + &local.media_connections_found.to_string(), + ], + )); + } + } if local.needs_rescan { text.push(' '); text.push_str(&qbz_i18n::t( "The library already had folders: run a rescan to index the added ones.", )); } + // The server/scrobbler databases have already changed + // on disk. Rebind their process-wide stores now; without + // this, Settings keeps the target's old cached values + // until restart and makes a successful copy look lost. + crate::integrations_qt::init_for_user(&profile_dir); + crate::local_plex::init_for_user(&profile_dir); + crate::media_sync_qt::cancel_all(); + let jellyfin_gate = crate::media_sync_qt::media_server_state_guard( + qbz_app::settings::media_servers::MediaServerKind::Jellyfin, + ); + let subsonic_gate = crate::media_sync_qt::media_server_state_guard( + qbz_app::settings::media_servers::MediaServerKind::Subsonic, + ); + crate::media_servers_qt::init_for_user(&profile_dir); + crate::source_wiring::bind_user(&profile_dir, session.user_id); + drop(subsonic_gate); + drop(jellyfin_gate); + crate::local_bridge_ops::publish_media_gates(); text.push(' '); text.push_str(&qbz_i18n::t( "Restart QBZ to see the migrated local profile.", @@ -502,12 +679,17 @@ pub async fn migrate(value: String) { } .await; let ok = result.is_ok(); - set_migration_status(match result { - Ok(text) | Err(text) => text, - }); - MIGRATION_BUSY.store(false, Ordering::SeqCst); if ok { + set_migration_step(6, qbz_i18n::t("Refreshing migrated data…")); + super::publish_snapshot().await; crate::library_qt::warm_favorites_cache(&runtime).await; crate::sidebar_qt::load(&runtime).await; } + set_migration_status(match result { + Ok(text) | Err(text) => text, + }); + MIGRATION_SUCCEEDED.store(ok, Ordering::Relaxed); + MIGRATION_RUNNING.store(false, Ordering::SeqCst); + MIGRATION_BUSY.store(false, Ordering::SeqCst); + super::publish_snapshot().await; } diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 27a0f6d1d..0155aa972 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -936,8 +936,8 @@ impl Default for QbzShellRust { window_width, window_height, window_maximized: crate::settings_qt::window_maximized(), - window_min_width: crate::settings_qt::WINDOW_MIN_WIDTH, - window_min_height: crate::settings_qt::WINDOW_MIN_HEIGHT, + window_min_width: crate::settings_qt::window_min_size().0, + window_min_height: crate::settings_qt::window_min_size().1, reduce_motion: reduce_motion_at_boot(), // The tier starts GPU-capable: both platforms were MEASURED on the // GPU (OpenGL RHI / Metal, 2026-07-29), so it is the honest prior, diff --git a/crates/qbz-subsonic/src/lib.rs b/crates/qbz-subsonic/src/lib.rs index 222d9785b..5143612ca 100644 --- a/crates/qbz-subsonic/src/lib.rs +++ b/crates/qbz-subsonic/src/lib.rs @@ -489,6 +489,30 @@ pub enum SweepMode { PerAlbum, } +/// The server's own media-library scan state (`getScanStatus`). `count` is +/// the number of items processed so far; the Subsonic protocol exposes no +/// total, so callers must not turn it into a percentage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScanStatus { + pub scanning: bool, + pub count: u64, +} + +fn scan_status_of(response: &serde_json::Value) -> Result { + let status = response + .get("scanStatus") + .ok_or_else(|| SubsonicError::Decode("response has no scanStatus member".into()))?; + let scanning = status + .get("scanning") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| SubsonicError::Decode("scanStatus has no scanning flag".into()))?; + let count = status + .get("count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + Ok(ScanStatus { scanning, count }) +} + // --------------------------------------------------------------------------- // Client // --------------------------------------------------------------------------- @@ -616,6 +640,15 @@ impl SubsonicClient { }) } + /// `getScanStatus.view` — the server's own library refresh, distinct from + /// QBZ copying that catalog into its local cache. Navidrome implements the + /// standard endpoint and may expose incomplete/disabled rows while this is + /// true, so callers should defer an authoritative QBZ sweep. + pub async fn scan_status(&self) -> Result { + let response = self.get("getScanStatus.view", "").await?; + scan_status_of(&response) + } + /// `getMusicFolders.view`. pub async fn music_folders(&self) -> Result> { let r = self.get("getMusicFolders.view", "").await?; @@ -895,6 +928,28 @@ mod tests { assert_eq!(v["musicFolders"]["musicFolder"][0]["name"], "Music Library"); } + #[test] + fn scan_status_preserves_server_progress_without_inventing_a_total() { + let body = br#"{"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","scanStatus":{"scanning":true,"count":5512}}}"#; + let response = parse_envelope(body).expect("ok envelope"); + assert_eq!( + scan_status_of(&response).unwrap(), + ScanStatus { + scanning: true, + count: 5_512, + } + ); + } + + #[test] + fn malformed_scan_status_cannot_claim_the_server_is_idle() { + let response = serde_json::json!({ "scanStatus": { "count": 12 } }); + assert!(matches!( + scan_status_of(&response), + Err(SubsonicError::Decode(_)) + )); + } + #[test] fn an_envelope_without_a_valid_status_cannot_authorize_empty_results() { let body = br#"{"subsonic-response":{"searchResult3":{}}}"#;