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
This commit is contained in:
vicrodh committed 2026-09-02 14:33:08 -06:00
1 parent 015ca48994
commit 472706f84e
38 files changed
+2962 -275

No files matched your search

+182 -5
View File
@@ -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<String>,
}
@@ -121,6 +126,60 @@ fn user_tables(conn: &Connection) -> Result<Vec<String>, 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<usize, String> {
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::<Vec<_>>()
.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]
@@ -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<A: MigrationApi, S: MigrationSink>(
api: &A,
user_id: u64,
display_name: &str,
email: &str,
sink: &S,
) -> Result<AccountSnapshot, String> {
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();
}
}
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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 "移行は追加のみです。どちらのアカウントからもお気に入りやプレイリストは削除されません。"
@@ -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."
@@ -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."
@@ -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 "Перенос только добавляет данные: избранное и плейлисты не удаляются ни из одного аккаунта."
+2
View File
@@ -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",
+19 -2
View File
@@ -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()
}
}
+46 -14
View File
@@ -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()
}
}
@@ -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) }
+81 -3
View File
@@ -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 === ""
+44 -7
View File
@@ -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)
}
}
}
+23 -1
View File
@@ -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)
}
}
+21 -3
View File
@@ -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()
}
}
@@ -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 {
+146 -3
View File
@@ -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()
}
}
+51 -10
View File
@@ -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()
}
}
@@ -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 {
@@ -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 {
@@ -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()
}
}
@@ -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()
}
}
@@ -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 {
@@ -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
}
}
}
}
}
}
}
}
@@ -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)
}
}
}
}
}
+22 -1
View File
@@ -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 {
+85 -4
View File
@@ -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
}
}
+3 -2
View File
@@ -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
+10 -2
View File
@@ -2090,7 +2090,9 @@ fn media_kind(server: &QString) -> Option<qbz_app::settings::media_servers::Medi
async fn run_media_sync(kind: qbz_app::settings::media_servers::MediaServerKind, full: bool) {
use qbz_app::settings::media_servers::MediaServerKind;
let result = match kind {
MediaServerKind::Jellyfin => 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),
}
}
+22
View File
@@ -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
+67 -8
View File
@@ -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<SyncReport, String> {
pub async fn sync_subsonic(_full: bool) -> Result<SyncReport, SyncError> {
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<SyncReport, String> {
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());
+109 -51
View File
@@ -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::<f32>().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<String, serde_json::Value>, 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<AppRuntime<LoggingAdapter>>, 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"]);
+254 -72
View File
@@ -24,11 +24,22 @@ static SETTINGS_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(St
static BLACKLIST_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static SNAPSHOT_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static MIGRATION_STATUS: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static MIGRATION_SOURCE: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
static MIGRATION_TARGET: LazyLock<Mutex<String>> = 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>) -> String {
status.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
@@ -55,8 +73,10 @@ fn read(status: &Mutex<String>) -> 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<SnapshotRow>,
}
@@ -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<SnapshotRow> {
let Ok(root) = UserDataPaths::global_data_dir() else {
return Vec::new();
@@ -103,15 +155,16 @@ fn snapshot_rows() -> Vec<SnapshotRow> {
.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<String>) -> impl Fn(MigrationEvent) + Send + Sync {
fn progress_sink(
status: &'static Mutex<String>,
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<String, String> = 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<String, String> = 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/<uid>/ → 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;
}
+2 -2
View File
@@ -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,
+55
View File
@@ -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<ScanStatus> {
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<ScanStatus> {
let response = self.get("getScanStatus.view", "").await?;
scan_status_of(&response)
}
/// `getMusicFolders.view`.
pub async fn music_folders(&self) -> Result<Vec<MusicFolder>> {
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":{}}}"#;