feat(qt): HiFi Wizard — guided DAC setup ported from Slint
Six-step modal (Welcome / Check / DACs / Configure / Test / Done) opened from Settings > Audio. It probes the audio stack and renders per-distro remediations, enumerates DACs with a manual node.name escape hatch, generates the PipeWire / WirePlumber snippets to copy, and plays four tracks spanning 16/44.1 to 24/192 with a live requested-vs-negotiated read-back. Read-only: it never writes a system file or runs a command. The logic was unreachable from Qt — every public function in qbz-dac-wizard takes a &AppWindow and writes straight into Slint globals. It is lifted into a new frontend-agnostic crate, qbz-dac-wizard-core, which returns plain data. qbz-dac-wizard itself is left untouched. Rust owns everything it computes plus `open`, so the reset and the probe are one act; QML owns the step navigation and the four progress checkboxes, which is how the reference splits them too. Two deliberate divergences from the reference: the row sits at the end of OUTPUT rather than BIT-PERFECT, and it is offered on every backend instead of PipeWire only — someone on ALSA direct is exactly who needs the walkthrough. The green glyphs use the accent tint, not success: success has no qrc bake directory, so a degraded tint set would render nothing at all. Every success-toned text keeps theme.success (#3fae6a verbatim). Verified by 309 tests, both QML audits and the offscreen smoke, plus a forced-route probe that mounts Settings and walks the wizard's steps — the standard smoke only mounts Home, so it is no evidence for a modal. The modal was also driven and screenshotted over VNC. cc
This commit is contained in:
28 files changed
+3983
-3
No files matched your search
Generated
+10
@@ -6359,6 +6359,15 @@ dependencies = [
|
||||
"slint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-dac-wizard-core"
|
||||
version = "2.0.2"
|
||||
dependencies = [
|
||||
"qbz-audio",
|
||||
"qbz-i18n",
|
||||
"qbz-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qbz-dsd"
|
||||
version = "2.0.2"
|
||||
@@ -6634,6 +6643,7 @@ dependencies = [
|
||||
"qbz-cast",
|
||||
"qbz-core",
|
||||
"qbz-credentials",
|
||||
"qbz-dac-wizard-core",
|
||||
"qbz-external-reco",
|
||||
"qbz-i18n",
|
||||
"qbz-integrations",
|
||||
|
||||
@@ -24,6 +24,14 @@ members = [
|
||||
"qconnect-transport-ws",
|
||||
"qconnect-app",
|
||||
"qbz-ui",
|
||||
# The HiFi Wizard's frontend-agnostic core (ADR-006), extracted for the Qt
|
||||
# port. `qbz-dac-wizard` is the SLINT one and is deliberately NOT wired to
|
||||
# this crate: the Slint tree is frozen (owner, 2026-08-11 — "ya no hacemos
|
||||
# cambios para Slint"), and rewiring it would have been a change to a tree
|
||||
# nobody compiles or tests any more. The two therefore hold the same logic
|
||||
# twice; that is safe only BECAUSE the Slint side is frozen, and it is the
|
||||
# reason a fix here never needs porting back.
|
||||
"qbz-dac-wizard-core",
|
||||
"qbz-dac-wizard",
|
||||
"qbz-text-utils",
|
||||
"qbz-slint-common",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "qbz-dac-wizard-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "HiFi/DAC setup wizard — frontend-agnostic core (probes, remediations, config generation)."
|
||||
|
||||
[lib]
|
||||
name = "qbz_dac_wizard_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
# Deliberately NO frontend dependency (no slint, no qbz-ui, no cxx-qt). ADR-006:
|
||||
# the core is frontend-agnostic so Slint, Qt and qbzd consume the SAME logic.
|
||||
# Everything here returns plain data; pushing it into a UI is the frontend's job.
|
||||
[dependencies]
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-i18n = { path = "../qbz-i18n" }
|
||||
@@ -0,0 +1,774 @@
|
||||
//! HiFi Wizard (DAC setup) — the frontend-agnostic core.
|
||||
//!
|
||||
//! # Why this crate exists
|
||||
//!
|
||||
//! The wizard shipped as `qbz-dac-wizard`, whose every public function takes a
|
||||
//! `&AppWindow` and writes straight into Slint globals — so the Qt port could
|
||||
//! not reach any of it. This crate is that logic, lifted out and returning
|
||||
//! plain data: `qbz-qt` drives it through `dac_wizard_qt.rs`. ADR-006,
|
||||
//! frontend-agnostic core.
|
||||
//!
|
||||
//! # Its relationship to `qbz-dac-wizard` — read before "deduplicating"
|
||||
//!
|
||||
//! `qbz-dac-wizard` still holds its own copy of this logic and is NOT wired to
|
||||
//! this crate. That is deliberate: the Slint tree is FROZEN (owner,
|
||||
//! 2026-08-11 — "ya no hacemos cambios para Slint"), so rewiring it would have
|
||||
//! been an unverifiable change to a tree nobody compiles or tests any more,
|
||||
//! bought for a deduplication that a frozen tree cannot benefit from.
|
||||
//!
|
||||
//! The usual objection to two copies is drift. It does not apply while the
|
||||
//! other copy never changes — and if Slint is ever unfrozen, the merge is the
|
||||
//! diff of one file. Fixes land HERE; nothing needs porting back.
|
||||
//!
|
||||
//! `qbzd` duplicates parts of this again in `tui/wizard_core.rs`. THAT one is
|
||||
//! live code and is a genuine candidate to fold onto this crate.
|
||||
//!
|
||||
//! # What is guaranteed
|
||||
//!
|
||||
//! READ-ONLY. Nothing here writes a system file, opens a stream or runs a
|
||||
//! command. It probes (`qbz_audio::health`, sink enumeration, rate queries)
|
||||
//! and it formats text. That property is the wizard's whole safety story and
|
||||
//! it is enforced by construction: this crate has no `std::process::Command`
|
||||
//! and no write-side `std::fs`.
|
||||
|
||||
use qbz_audio::{AudioStackHealth, Distro, InitSystem, Sandbox};
|
||||
|
||||
// ===========================================================================
|
||||
// Environment detection (thin re-exports so a frontend needs ONE dependency)
|
||||
// ===========================================================================
|
||||
|
||||
/// Distro dropdown options, in `Distro::ALL` order (index = the dropdown's).
|
||||
pub fn distro_options() -> Vec<String> {
|
||||
Distro::ALL.iter().map(|d| d.label().to_string()).collect()
|
||||
}
|
||||
|
||||
/// Init-system dropdown options, in `InitSystem::ALL` order.
|
||||
pub fn init_options() -> Vec<String> {
|
||||
InitSystem::ALL
|
||||
.iter()
|
||||
.map(|i| i.label().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Auto-detected distro, as a dropdown index.
|
||||
pub fn detected_distro_index() -> i32 {
|
||||
qbz_audio::detect_distro().index() as i32
|
||||
}
|
||||
|
||||
/// Auto-detected init system, as a dropdown index.
|
||||
pub fn detected_init_index() -> i32 {
|
||||
qbz_audio::detect_init().index() as i32
|
||||
}
|
||||
|
||||
/// `("Flatpak" | "Snap" | "")` — empty when the process is not sandboxed.
|
||||
pub fn sandbox_name() -> &'static str {
|
||||
match qbz_audio::detect_sandbox() {
|
||||
Sandbox::Flatpak => "Flatpak",
|
||||
Sandbox::Snap => "Snap",
|
||||
Sandbox::None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking audio-stack probe (shells out through `qbz_audio`; call it off the
|
||||
/// UI thread).
|
||||
pub fn probe_health() -> AudioStackHealth {
|
||||
qbz_audio::audio_stack_health()
|
||||
}
|
||||
|
||||
fn distro_at(index: i32) -> Distro {
|
||||
Distro::ALL
|
||||
.get(index.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(Distro::Other)
|
||||
}
|
||||
|
||||
fn init_at(index: i32) -> InitSystem {
|
||||
InitSystem::ALL
|
||||
.get(index.max(0) as usize)
|
||||
.copied()
|
||||
.unwrap_or(InitSystem::Unknown)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Check step — health verdict + per-distro remediations
|
||||
// ===========================================================================
|
||||
|
||||
/// One "you're missing X" fix: a caption and a copy-paste command.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Remediation {
|
||||
pub caption: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// Everything the check step renders, for one (health, distro, init) triple.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CheckView {
|
||||
pub health_ok: bool,
|
||||
/// Empty in a sandbox — there is no verdict to give, only reference
|
||||
/// commands (the UI shows its sandbox banner instead).
|
||||
pub summary: String,
|
||||
pub remediations: Vec<Remediation>,
|
||||
}
|
||||
|
||||
/// Build the check step from a completed probe and the two dropdown indices
|
||||
/// (either of which the user may have overridden).
|
||||
///
|
||||
/// In a sandbox the host probes are blind, so no verdict is rendered — the
|
||||
/// caller gets reference setup commands for the chosen distro/init instead
|
||||
/// (Tauri-style, which never probed either).
|
||||
pub fn check_view(
|
||||
health: AudioStackHealth,
|
||||
distro_index: i32,
|
||||
init_index: i32,
|
||||
sandboxed: bool,
|
||||
) -> CheckView {
|
||||
let distro = distro_at(distro_index);
|
||||
let init = init_at(init_index);
|
||||
|
||||
if sandboxed {
|
||||
return CheckView {
|
||||
health_ok: false,
|
||||
summary: String::new(),
|
||||
remediations: reference_commands(distro, init),
|
||||
};
|
||||
}
|
||||
|
||||
let rows = remediations(health, distro, init);
|
||||
let ok = health.is_ready();
|
||||
let summary = if ok {
|
||||
qbz_i18n::t("Your audio stack is ready for bit-perfect playback.")
|
||||
} else {
|
||||
let n = rows.len();
|
||||
qbz_i18n::tf(
|
||||
"{} item needs attention before bit-perfect playback will work.",
|
||||
"{} items need attention before bit-perfect playback will work.",
|
||||
n as i64,
|
||||
&[&n.to_string()],
|
||||
)
|
||||
};
|
||||
CheckView {
|
||||
health_ok: ok,
|
||||
summary,
|
||||
remediations: rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// (caption, copy-paste command) per missing probe, for the given distro.
|
||||
///
|
||||
/// Service/restart commands are INIT-SYSTEM aware per distro (OpenRC on Gentoo,
|
||||
/// runit on Void, systemd elsewhere), mirroring the Tauri DistroSelector
|
||||
/// `restartCommands`. Installs and the restart are kept as separate blocks so
|
||||
/// the multi-line Gentoo guidance never gets `&&`-joined.
|
||||
fn remediations(h: AudioStackHealth, d: Distro, init: InitSystem) -> Vec<Remediation> {
|
||||
// NixOS is declarative: you don't `apt/pacman install` pieces — you enable
|
||||
// the PipeWire module and rebuild. So collapse all the missing pieces into
|
||||
// one config block instead of per-package commands.
|
||||
if d == Distro::NixOS {
|
||||
if h.is_ready() {
|
||||
return Vec::new();
|
||||
}
|
||||
return vec![Remediation {
|
||||
caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
command: NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
}];
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut needs_restart = false;
|
||||
if !h.has_pw_dump {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the PipeWire tools (pw-dump)"),
|
||||
command: install(d, pkg_pw_tools(d)),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.cpal_sees_pipewire {
|
||||
// THE Ubuntu no-list / no-playback bug: the ALSA->PipeWire bridge PCM.
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the ALSA bridge so playback can reach PipeWire"),
|
||||
command: install(d, "pipewire-alsa"),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.has_pactl {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("Install the Pulse compatibility tools (optional fallback)"),
|
||||
command: install(d, pkg_pulse(d)),
|
||||
});
|
||||
needs_restart = true;
|
||||
}
|
||||
if !h.any_devices {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t(
|
||||
"No sinks detected — reinstall the ALSA UCM profiles, then reboot",
|
||||
),
|
||||
command: install_reinstall(d, "alsa-ucm-conf"),
|
||||
});
|
||||
}
|
||||
// WirePlumber down, or we just installed something → (re)start the stack
|
||||
// with the ACTUAL init system running on this machine (not guessed from the
|
||||
// distro — Gentoo+systemd and Gentoo+OpenRC must differ).
|
||||
if !h.wireplumber_active || needs_restart {
|
||||
out.push(Remediation {
|
||||
caption: qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
command: restart_cmd(init).to_string(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Init-system-aware "(re)start the audio services" command. PipeWire is a
|
||||
/// user-session service, so only systemd has a first-class `--user` restart;
|
||||
/// the others either use their user-service supervisor or a re-login.
|
||||
fn restart_cmd(init: InitSystem) -> &'static str {
|
||||
match init {
|
||||
InitSystem::Systemd => "systemctl --user restart pipewire pipewire-pulse wireplumber",
|
||||
InitSystem::OpenRc => {
|
||||
"# OpenRC: PipeWire runs in your user session, not as an OpenRC service.\n\
|
||||
# Log out and back in to restart it."
|
||||
}
|
||||
InitSystem::Runit => {
|
||||
"sv restart pipewire wireplumber # if set up as runit user services; otherwise log out and back in"
|
||||
}
|
||||
InitSystem::S6 => "# s6: restart via your supervision tree, or log out and back in",
|
||||
InitSystem::Dinit => "dinitctl restart pipewire wireplumber # or log out and back in",
|
||||
InitSystem::Unknown => "# Restart PipeWire via your init system, or log out and back in",
|
||||
}
|
||||
}
|
||||
|
||||
/// The restart command for a dropdown index — the review step's step 3.
|
||||
pub fn restart_command_for(init_index: i32) -> String {
|
||||
restart_cmd(init_at(init_index)).to_string()
|
||||
}
|
||||
|
||||
fn pkg_pw_tools(d: Distro) -> &'static str {
|
||||
match d {
|
||||
// Debian-family (incl. antiX) ship pw-* in pipewire-bin.
|
||||
Distro::Debian | Distro::Antix => "pipewire-bin",
|
||||
Distro::Fedora => "pipewire-utils",
|
||||
// Arch (incl. Artix) / openSUSE / Gentoo / Void ship pw-* with pipewire.
|
||||
_ => "pipewire",
|
||||
}
|
||||
}
|
||||
|
||||
fn pkg_pulse(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => "pipewire-pulse pulseaudio-utils",
|
||||
Distro::Fedora => "pipewire-pulseaudio",
|
||||
_ => "pipewire-pulse",
|
||||
}
|
||||
}
|
||||
|
||||
fn install(d: Distro, pkgs: &str) -> String {
|
||||
match d {
|
||||
// Package manager is a property of the distro family, NOT the init.
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install {pkgs}"),
|
||||
Distro::Fedora => format!("sudo dnf install {pkgs}"),
|
||||
Distro::Arch | Distro::Artix => format!("sudo pacman -S {pkgs}"),
|
||||
Distro::OpenSuse => format!("sudo zypper install {pkgs}"),
|
||||
Distro::Gentoo => format!("sudo emerge {pkgs} # package name may differ on Gentoo"),
|
||||
Distro::Void => format!("sudo xbps-install -S {pkgs}"),
|
||||
// NixOS is special-cased in remediations(); this is an unreached fallback.
|
||||
Distro::NixOS => {
|
||||
format!("# NixOS: add to configuration.nix (see the PipeWire block) — {pkgs}")
|
||||
}
|
||||
Distro::Other => format!("Install with your package manager: {pkgs}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install_reinstall(d: Distro, pkg: &str) -> String {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => format!("sudo apt install --reinstall {pkg}"),
|
||||
Distro::Fedora => format!("sudo dnf reinstall {pkg}"),
|
||||
_ => install(d, pkg),
|
||||
}
|
||||
}
|
||||
|
||||
const NIXOS_PIPEWIRE_BLOCK: &str = "# /etc/nixos/configuration.nix:\n\
|
||||
services.pipewire = {\n\
|
||||
\u{20}\u{20}enable = true;\n\
|
||||
\u{20}\u{20}alsa.enable = true;\n\
|
||||
\u{20}\u{20}pulse.enable = true;\n\
|
||||
\u{20}\u{20}wireplumber.enable = true;\n\
|
||||
};\n\
|
||||
# then apply:\n\
|
||||
sudo nixos-rebuild switch";
|
||||
|
||||
/// Full reference setup commands for the chosen distro/init, shown when QBZ
|
||||
/// can't probe the host (sandbox). Mirrors the Tauri DistroSelector, which
|
||||
/// always showed per-distro install + restart commands (no probing).
|
||||
fn reference_commands(d: Distro, init: InitSystem) -> Vec<Remediation> {
|
||||
if d == Distro::NixOS {
|
||||
return vec![Remediation {
|
||||
caption: qbz_i18n::t("Enable PipeWire in your NixOS configuration"),
|
||||
command: NIXOS_PIPEWIRE_BLOCK.to_string(),
|
||||
}];
|
||||
}
|
||||
vec![
|
||||
Remediation {
|
||||
caption: qbz_i18n::t("Install the PipeWire audio stack"),
|
||||
command: install(d, full_stack_pkgs(d)),
|
||||
},
|
||||
Remediation {
|
||||
caption: qbz_i18n::t("(Re)start the PipeWire audio services"),
|
||||
command: restart_cmd(init).to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The full recommended package set (incl. `pipewire-alsa`, the bit the old
|
||||
/// Tauri list omitted — the cause of the Ubuntu empty-list bug).
|
||||
fn full_stack_pkgs(d: Distro) -> &'static str {
|
||||
match d {
|
||||
Distro::Debian | Distro::Antix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::Fedora => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Arch | Distro::Artix => {
|
||||
"pipewire pipewire-pulse pipewire-alsa wireplumber alsa-utils"
|
||||
}
|
||||
Distro::OpenSuse => "pipewire pipewire-pulseaudio pipewire-alsa wireplumber alsa-utils",
|
||||
Distro::Gentoo => "media-video/pipewire media-video/wireplumber media-sound/alsa-utils",
|
||||
Distro::Void => "pipewire wireplumber alsa-utils",
|
||||
Distro::NixOS => "",
|
||||
Distro::Other => "pipewire pipewire-pulse wireplumber alsa-utils",
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Select-DACs step — enumeration + the manual escape hatch
|
||||
// ===========================================================================
|
||||
|
||||
/// One enumerated sink the user can pick to configure.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DacCandidate {
|
||||
/// PipeWire `node.name` — the value the manual escape hatch asks for.
|
||||
pub id: String,
|
||||
/// Pretty name, e.g. "DacMagic Plus Analog Stereo".
|
||||
pub description: String,
|
||||
/// "usb" | "pci" | "bluetooth" | ""
|
||||
pub bus: String,
|
||||
pub is_default: bool,
|
||||
/// hardware && bus ∈ {usb,pci} → pre-selected.
|
||||
pub looks_like_dac: bool,
|
||||
/// Pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz".
|
||||
pub rates_label: String,
|
||||
}
|
||||
|
||||
/// Enumerate sinks via the pw-dump-robust path and probe rates for the likely
|
||||
/// DACs. BLOCKING — call it off the UI thread.
|
||||
pub fn detect_blocking() -> Vec<DacCandidate> {
|
||||
let devices = qbz_audio::backend::BackendManager::create_backend(
|
||||
qbz_audio::backend::AudioBackendType::PipeWire,
|
||||
)
|
||||
.and_then(|b| b.enumerate_devices())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for d in devices {
|
||||
let bus = d.device_bus.unwrap_or_default();
|
||||
let looks_like_dac = d.is_hardware && (bus == "usb" || bus == "pci");
|
||||
// Only probe rates for likely DACs (skip virtual/monitor sinks).
|
||||
let rates_label = if looks_like_dac {
|
||||
format_rates(&qbz_audio::query_dac_capabilities(&d.id).sample_rates)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let description = if d.name.is_empty() {
|
||||
d.id.clone()
|
||||
} else {
|
||||
d.name
|
||||
};
|
||||
out.push(DacCandidate {
|
||||
id: d.id,
|
||||
description,
|
||||
bus,
|
||||
is_default: d.is_default,
|
||||
looks_like_dac,
|
||||
rates_label,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a manually-pasted `node.name`. 1:1 with the Tauri
|
||||
/// `validateNodeName`.
|
||||
pub fn validate_node_name(name: &str) -> bool {
|
||||
let t = name.trim();
|
||||
!t.is_empty() && (t.contains("alsa_output") || t.contains("alsa_input"))
|
||||
}
|
||||
|
||||
/// Classify a pasted node name. 1:1 with the Tauri `detectDacType`.
|
||||
pub fn detect_dac_type(name: &str) -> &'static str {
|
||||
let l = name.to_lowercase();
|
||||
if l.contains("usb-") || l.contains(".usb") {
|
||||
"usb"
|
||||
} else if l.contains("pci-") || l.contains(".pci") {
|
||||
"pci"
|
||||
} else if l.contains("bluez") || l.contains("bluetooth") {
|
||||
"bluetooth"
|
||||
} else if l.contains("virtual") || l.contains("null") || l.contains("dummy") {
|
||||
"virtual"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// "44.1 / 96 / 192 kHz" from a rate list (kHz, .1 only when non-integer).
|
||||
pub fn format_rates(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let parts: Vec<String> = rates
|
||||
.iter()
|
||||
.map(|&r| {
|
||||
if r % 1000 == 0 {
|
||||
format!("{}", r / 1000)
|
||||
} else {
|
||||
format!("{:.1}", r as f64 / 1000.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!("{} kHz", parts.join(" / "))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Test step — the four curated tracks + the N6 read-back
|
||||
// ===========================================================================
|
||||
|
||||
/// One curated test track (owner-provided). Resolved by id-hint first, then by
|
||||
/// "artist title" search if the id 404s (a pulled license) — never raw-id-only.
|
||||
pub struct TestSeed {
|
||||
pub depth: u32,
|
||||
pub rate: f64,
|
||||
pub id_hint: u64,
|
||||
pub artist: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
|
||||
pub const TEST_SEEDS: [TestSeed; 4] = [
|
||||
TestSeed { depth: 16, rate: 44100.0, id_hint: 19301386, artist: "George Harrison", title: "My Sweet Lord" },
|
||||
TestSeed { depth: 24, rate: 44100.0, id_hint: 266725027, artist: "Billie Eilish", title: "LUNCH" },
|
||||
TestSeed { depth: 24, rate: 96000.0, id_hint: 126886854, artist: "Iron Maiden", title: "Stratego" },
|
||||
TestSeed { depth: 24, rate: 192000.0, id_hint: 52265, artist: "Toto", title: "Africa" },
|
||||
];
|
||||
|
||||
/// True if a resolved track matches this seed's family (rate + bit depth — the
|
||||
/// two 44.1 seeds only differ by depth).
|
||||
pub fn track_matches_seed(track: &qbz_models::Track, seed: &TestSeed) -> bool {
|
||||
let rate_ok = track
|
||||
.maximum_sampling_rate
|
||||
.map(|r| (r * 1000.0 - seed.rate).abs() < 1.0 || (r - seed.rate).abs() < 1.0)
|
||||
.unwrap_or(false);
|
||||
let depth_ok = track
|
||||
.maximum_bit_depth
|
||||
.map(|d| d == seed.depth)
|
||||
.unwrap_or(false);
|
||||
rate_ok && depth_ok
|
||||
}
|
||||
|
||||
/// The two read-back lines plus the truth signal, for one poll.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PollView {
|
||||
pub requested_label: String,
|
||||
pub negotiated_label: String,
|
||||
/// The DAC's real clock matches what QBZ asked for.
|
||||
pub rate_matched: bool,
|
||||
}
|
||||
|
||||
/// Format one poll: the rate QBZ requested vs the DAC's real negotiated rate
|
||||
/// (N6).
|
||||
pub fn poll_view(
|
||||
requested_rate: u32,
|
||||
requested_bits: u32,
|
||||
negotiated: Option<qbz_audio::NegotiatedRate>,
|
||||
) -> PollView {
|
||||
let requested_label = if requested_rate > 0 {
|
||||
qbz_i18n::t_args(
|
||||
"QBZ requesting {} · {}-bit",
|
||||
&[&khz(requested_rate), &requested_bits.to_string()],
|
||||
)
|
||||
} else {
|
||||
qbz_i18n::t("Nothing playing")
|
||||
};
|
||||
match negotiated {
|
||||
Some(n) => PollView {
|
||||
requested_label,
|
||||
// The DAC's REAL hardware params (N6): rate + ALSA container format
|
||||
// (e.g. S32_LE = 24-bit in a 32-bit frame) + channels. This is the
|
||||
// bit-perfect proof — exactly what the hardware is clocked at.
|
||||
negotiated_label: qbz_i18n::t_args(
|
||||
"DAC: {} · {} · {} ch",
|
||||
&[&khz(n.sample_rate), &n.format, &n.channels.to_string()],
|
||||
),
|
||||
rate_matched: requested_rate > 0 && n.sample_rate == requested_rate,
|
||||
},
|
||||
None => PollView {
|
||||
requested_label,
|
||||
negotiated_label: qbz_i18n::t("Waiting for the DAC to start playing…"),
|
||||
rate_matched: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn khz(hz: u32) -> String {
|
||||
if hz % 1000 == 0 {
|
||||
format!("{} kHz", hz / 1000)
|
||||
} else {
|
||||
format!("{:.1} kHz", hz as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Review-and-apply step — per-DAC config generation
|
||||
// ===========================================================================
|
||||
|
||||
/// One selected DAC's generated copy-paste config (read-only).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DacConfig {
|
||||
pub name: String,
|
||||
pub node_name: String,
|
||||
pub pipewire_conf: String,
|
||||
pub pulse_conf: String,
|
||||
pub wireplumber_conf: String,
|
||||
}
|
||||
|
||||
/// Re-probe rates + build the three config snippets per DAC. BLOCKING.
|
||||
pub fn gen_configs_blocking(dacs: Vec<(String, String)>) -> Vec<DacConfig> {
|
||||
dacs.into_iter()
|
||||
.map(|(node_name, name)| {
|
||||
let rates = qbz_audio::query_dac_capabilities(&node_name).sample_rates;
|
||||
let short = short_name(&name, &node_name);
|
||||
DacConfig {
|
||||
pipewire_conf: pipewire_conf(&short, &rates),
|
||||
pulse_conf: pulse_conf(&short),
|
||||
wireplumber_conf: wireplumber_conf(&short, &node_name, &rates, &name),
|
||||
name,
|
||||
node_name,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The three `~/.config/...` paths one generated config would create — the
|
||||
/// done step's "Config files you can create" list, in the same order the
|
||||
/// review step shows the snippets.
|
||||
pub fn created_paths(configs: &[DacConfig]) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
for d in configs {
|
||||
let short = short_name(&d.name, &d.node_name);
|
||||
paths.push(format!(
|
||||
"~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf"
|
||||
));
|
||||
paths.push(format!(
|
||||
"~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf"
|
||||
));
|
||||
paths.push(format!(
|
||||
"~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf"
|
||||
));
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
pub const BACKUP_CMD: &str = "BACKUP=~/.config/qbz/backups/pipewire-$(date +%Y%m%d-%H%M%S)\nmkdir -p \"$BACKUP\"\ncp -a ~/.config/pipewire \"$BACKUP/\" 2>/dev/null || true\ncp -a ~/.config/wireplumber \"$BACKUP/\" 2>/dev/null || true\necho \"Backup created at: $BACKUP\"";
|
||||
|
||||
/// A short, filename-safe DAC name: slug of the description, else the node.name.
|
||||
fn short_name(name: &str, node_name: &str) -> String {
|
||||
let slug = slugify(name);
|
||||
if !slug.is_empty() {
|
||||
return slug;
|
||||
}
|
||||
let nslug = slugify(node_name);
|
||||
if nslug.is_empty() {
|
||||
"dac".to_string()
|
||||
} else {
|
||||
nslug
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn rates_list(rates: &[u32]) -> String {
|
||||
if rates.is_empty() {
|
||||
"44100 48000 88200 96000 176400 192000".to_string()
|
||||
} else {
|
||||
rates
|
||||
.iter()
|
||||
.map(|r| r.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn pipewire_conf(short: &str, rates: &[u32]) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/pipewire.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/pipewire.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Sample Rate Switching".to_string(),
|
||||
"context.properties = {".to_string(),
|
||||
format!(" default.clock.allowed-rates = [ {rates} ]"),
|
||||
"}".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn pulse_conf(short: &str) -> String {
|
||||
[
|
||||
"mkdir -p ~/.config/pipewire/client.conf.d".to_string(),
|
||||
format!("cat > ~/.config/pipewire/client.conf.d/99-qbz-bitperfect-{short}.conf << 'EOF'"),
|
||||
"# QBZ DAC Setup - Per-App Bit-Perfect".to_string(),
|
||||
"stream.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
" { application.process.binary = \"qbz\" }".to_string(),
|
||||
" { application.name = \"PipeWire ALSA [qbz]\" }".to_string(),
|
||||
" ]".to_string(),
|
||||
" actions = { update-props = { resample.disable = true, channelmix.disable = true } }"
|
||||
.to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn wireplumber_conf(short: &str, node_name: &str, rates: &[u32], description: &str) -> String {
|
||||
let rates = rates_list(rates);
|
||||
[
|
||||
"mkdir -p ~/.config/wireplumber/wireplumber.conf.d".to_string(),
|
||||
format!("cat > ~/.config/wireplumber/wireplumber.conf.d/99-qbz-dac-{short}.conf << 'EOF'"),
|
||||
format!("# QBZ DAC Setup - {description}"),
|
||||
"monitor.alsa.rules = [".to_string(),
|
||||
" {".to_string(),
|
||||
" matches = [".to_string(),
|
||||
format!(" {{ node.name = \"{node_name}\", media.class = \"Audio/Sink\" }}"),
|
||||
" ]".to_string(),
|
||||
" actions = {".to_string(),
|
||||
" update-props = {".to_string(),
|
||||
format!(" audio.allowed-rates = [ {rates} ]"),
|
||||
" resample.disable = true".to_string(),
|
||||
" channelmix.disable = true".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
" }".to_string(),
|
||||
"]".to_string(),
|
||||
"EOF".to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_node_names_like_tauri() {
|
||||
assert!(validate_node_name(
|
||||
"alsa_output.usb-Cambridge-00.analog-stereo"
|
||||
));
|
||||
assert!(validate_node_name("alsa_input.pci-0000_00.analog-stereo"));
|
||||
assert!(!validate_node_name(""));
|
||||
assert!(!validate_node_name(" "));
|
||||
assert!(!validate_node_name("bluez_output.AA_BB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_dac_type() {
|
||||
assert_eq!(
|
||||
detect_dac_type("alsa_output.usb-Cambridge-00.analog-stereo"),
|
||||
"usb"
|
||||
);
|
||||
assert_eq!(
|
||||
detect_dac_type("alsa_output.pci-0000_00_1f.3.analog-stereo"),
|
||||
"pci"
|
||||
);
|
||||
assert_eq!(detect_dac_type("bluez_output.AA"), "bluetooth");
|
||||
assert_eq!(detect_dac_type("alsa_output.virtual-dummy"), "virtual");
|
||||
assert_eq!(detect_dac_type("something.else"), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_rates_khz() {
|
||||
assert_eq!(format_rates(&[44100, 96000, 192000]), "44.1 / 96 / 192 kHz");
|
||||
assert_eq!(format_rates(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugifies_descriptions() {
|
||||
assert_eq!(
|
||||
slugify("DacMagic Plus Analog Stereo"),
|
||||
"dacmagic-plus-analog-stereo"
|
||||
);
|
||||
assert_eq!(
|
||||
slugify("Built-in Audio Analog Stereo"),
|
||||
"built-in-audio-analog-stereo"
|
||||
);
|
||||
assert_eq!(slugify(" weird__name!! "), "weird-name");
|
||||
assert_eq!(slugify(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireplumber_conf_pins_node_and_rates() {
|
||||
let c = wireplumber_conf(
|
||||
"dacmagic",
|
||||
"alsa_output.usb-x.analog-stereo",
|
||||
&[44100, 192000],
|
||||
"DacMagic",
|
||||
);
|
||||
assert!(c.contains("node.name = \"alsa_output.usb-x.analog-stereo\""));
|
||||
assert!(c.contains("audio.allowed-rates = [ 44100 192000 ]"));
|
||||
assert!(c.contains("99-qbz-dac-dacmagic.conf"));
|
||||
assert!(c.contains("resample.disable = true"));
|
||||
}
|
||||
|
||||
/// The done step's path list must line up with the snippets the review step
|
||||
/// shows — three paths per DAC, in the pipewire / client / wireplumber
|
||||
/// order the accordions use.
|
||||
#[test]
|
||||
fn created_paths_are_three_per_dac_in_order() {
|
||||
let cfg = DacConfig {
|
||||
name: "DacMagic Plus".to_string(),
|
||||
node_name: "alsa_output.usb-x.analog-stereo".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let paths = created_paths(&[cfg]);
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert!(paths[0].contains("pipewire.conf.d/99-qbz-dac-dacmagic-plus.conf"));
|
||||
assert!(paths[1].contains("client.conf.d/99-qbz-bitperfect-dacmagic-plus.conf"));
|
||||
assert!(paths[2].contains("wireplumber.conf.d/99-qbz-dac-dacmagic-plus.conf"));
|
||||
}
|
||||
|
||||
/// A sandbox has no verdict to give: no summary, and the rows are the
|
||||
/// reference install + restart rather than probe-derived fixes.
|
||||
#[test]
|
||||
fn sandbox_renders_reference_commands_and_no_verdict() {
|
||||
// A fully-healthy probe, so the assertion cannot pass by accident: in
|
||||
// a sandbox the verdict is suppressed REGARDLESS of what was probed.
|
||||
let health = AudioStackHealth {
|
||||
wireplumber_active: true,
|
||||
has_pw_dump: true,
|
||||
cpal_sees_pipewire: true,
|
||||
has_pactl: true,
|
||||
any_devices: true,
|
||||
};
|
||||
let view = check_view(health, 0, 0, true);
|
||||
assert!(view.summary.is_empty());
|
||||
assert!(!view.health_ok);
|
||||
assert_eq!(view.remediations.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
qbz-core = { path = "../qbz-core" }
|
||||
qbz-app = { path = "../qbz-app" }
|
||||
qbz-audio = { path = "../qbz-audio" }
|
||||
# The HiFi Wizard's frontend-agnostic core. NOT `qbz-dac-wizard` — that one is
|
||||
# the Slint adapter and pulls in qbz-ui + slint, the ~1.6 M-line generated
|
||||
# module this whole track exists to stay out of.
|
||||
qbz-dac-wizard-core = { path = "../qbz-dac-wizard-core" }
|
||||
qbz-qobuz = { path = "../qbz-qobuz" }
|
||||
qbz-models = { path = "../qbz-models" }
|
||||
qbz-cache = { path = "../qbz-cache" }
|
||||
|
||||
@@ -51,7 +51,7 @@ fn main() {
|
||||
// (myqbz_qt.rs, blacklist_qt.rs, toast_qt.rs, …) are plain
|
||||
// modules and must NOT be listed — only files that declare a
|
||||
// #[cxx_qt::bridge] mod belong in this array.
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
|
||||
rust_files: &["src/bridge.rs", "src/session_bridge.rs", "src/shell_bridge.rs", "src/player_bridge.rs", "src/queue_bridge.rs", "src/home_bridge.rs", "src/viz_bridge.rs", "src/immersive_bridge.rs", "src/suggestions_bridge.rs", "src/hotkeys_bridge.rs", "src/search_bridge.rs", "src/local_bridge.rs", "src/library_bridge.rs", "src/album_bridge.rs", "src/artist_bridge.rs", "src/lyrics_qt.rs", "src/icon_tint_qt.rs", "src/cast_bridge.rs", "src/myqbz_bridge.rs", "src/myqbz_add_bridge.rs", "src/disco_bridge.rs", "src/blacklist_bridge.rs", "src/playlist_picker_bridge.rs", "src/playlist_manager_bridge.rs", "src/playlist_import_bridge.rs", "src/dac_wizard_bridge.rs", "src/folder_edit_bridge.rs", "src/playlist_edit_bridge.rs", "src/qconnect_bridge.rs", "src/kiosk_nav_bridge.rs", "src/mini_bridge.rs", "src/tray_bridge.rs"],
|
||||
qml_files: &[
|
||||
"qml/LoginScreen.qml",
|
||||
"qml/Main.qml",
|
||||
@@ -67,6 +67,7 @@ fn main() {
|
||||
"qml/cards/TrackCard.qml",
|
||||
"qml/controls/AddToMixtapeModal.qml",
|
||||
"qml/controls/CardMenu.qml",
|
||||
"qml/controls/CommandBlock.qml",
|
||||
"qml/controls/CardOverlayButton.qml",
|
||||
"qml/controls/CardOverlayRow.qml",
|
||||
"qml/controls/FolderEditPanel.qml",
|
||||
@@ -244,6 +245,7 @@ fn main() {
|
||||
"qml/controls/WarningBanner.qml",
|
||||
"qml/settings/AudioSettings.qml",
|
||||
"qml/settings/BlacklistSettings.qml",
|
||||
"qml/settings/DacWizardModal.qml",
|
||||
"qml/settings/DeveloperSettings.qml",
|
||||
"qml/settings/LibFolderEditModal.qml",
|
||||
"qml/settings/LibraryFolderTable.qml",
|
||||
|
||||
@@ -273,6 +273,14 @@ ApplicationWindow {
|
||||
// importer makes (the whole progress panel, the log, the summary) is
|
||||
// dropped on the floor with nothing logged on either side.
|
||||
QbzPlaylistImport.boot()
|
||||
// HiFi Wizard. Nothing to seed — the modal is closed until the
|
||||
// Settings > Audio row opens it — but without this line every publish
|
||||
// the wizard makes is dropped on the floor: the health verdict, the
|
||||
// enumerated DACs, the generated config and the live read-back would
|
||||
// all stay at their defaults, and nothing would be logged on either
|
||||
// side. The wizard is the loudest case after QbzBlacklist, because its
|
||||
// first step ends on "Checking your audio stack…" forever.
|
||||
QbzDacWizard.boot()
|
||||
// Miniplayer (2026-08-03 miniplayer/tray contract, block B1). Booted
|
||||
// like every other domain singleton, after QbzSession: `boot()` is what
|
||||
// registers the Qt-thread hop, and without it `mini_bridge::ui()` is a
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#4285f4" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#4285f4" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#4285f4" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#4285f4" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#4285f4" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#4285f4" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#4285f4" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#4285f4" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#000000" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#000000" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#000000" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#000000" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#000000" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#000000" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#888888" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#888888" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#888888" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#888888" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#888888" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#888888" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#888888" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#888888" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#ffffff" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#ffffff" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#ffffff" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#ffffff" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#ffffff" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#ffffff" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#ffffff" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#ffffff" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#cccccc" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#cccccc" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#cccccc" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#cccccc" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#cccccc" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#cccccc" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#cccccc" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#cccccc" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny" viewBox="0.00 0.00 100.00 100.00" width="100.00" height="100.00">
|
||||
<g>
|
||||
<path fill="#fbbf24" d="
|
||||
M 44.00 5.56
|
||||
Q 44.18 5.56 44.24 5.52
|
||||
C 48.35 3.31 54.84 4.30 59.34 6.39
|
||||
Q 73.67 13.03 75.28 28.51
|
||||
Q 77.00 45.04 81.97 93.69
|
||||
A 1.92 1.92 0.0 0 1 80.24 95.80
|
||||
L 80.13 95.81
|
||||
A 1.95 1.95 0.0 0 1 78.01 94.07
|
||||
Q 74.00 55.61 71.84 33.50
|
||||
Q 70.90 23.96 68.99 20.35
|
||||
C 64.91 12.62 56.73 7.78 47.81 8.34
|
||||
A 2.80 2.79 -61.0 0 0 46.66 8.67
|
||||
C 45.87 9.10 44.88 9.96 44.00 9.96
|
||||
C 43.12 9.96 42.13 9.10 41.34 8.67
|
||||
A 2.80 2.79 61.0 0 0 40.19 8.34
|
||||
C 31.27 7.78 23.09 12.62 19.01 20.35
|
||||
Q 17.10 23.96 16.16 33.50
|
||||
Q 14.00 55.61 9.98 94.07
|
||||
A 1.95 1.95 0.0 0 1 7.86 95.81
|
||||
L 7.75 95.80
|
||||
A 1.92 1.92 0.0 0 1 6.02 93.69
|
||||
Q 11.00 45.04 12.72 28.51
|
||||
Q 14.33 13.03 28.66 6.39
|
||||
C 33.16 4.30 39.65 3.31 43.76 5.52
|
||||
Q 43.82 5.56 44.00 5.56
|
||||
Z"
|
||||
/>
|
||||
<path fill="#fbbf24" d="
|
||||
M 88.00 4.33
|
||||
Q 88.20 4.33 88.27 4.44
|
||||
Q 97.14 18.60 97.64 19.43
|
||||
A 1.75 1.67 31.1 0 1 97.89 20.35
|
||||
Q 97.86 27.45 95.17 40.45
|
||||
Q 93.80 47.13 90.33 51.14
|
||||
A 1.37 1.34 65.9 0 0 90.00 52.03
|
||||
L 90.00 95.35
|
||||
A 0.65 0.65 0.0 0 1 89.35 96.00
|
||||
Q 89.05 96.00 88.00 96.00
|
||||
Q 86.95 96.00 86.65 96.00
|
||||
A 0.65 0.65 0.0 0 1 86.00 95.35
|
||||
L 86.00 52.03
|
||||
A 1.37 1.34 -65.9 0 0 85.67 51.14
|
||||
Q 82.19 47.13 80.82 40.45
|
||||
Q 78.13 27.45 78.10 20.35
|
||||
A 1.75 1.67 -31.1 0 1 78.35 19.43
|
||||
Q 78.85 18.60 87.72 4.44
|
||||
Q 87.79 4.33 88.00 4.33
|
||||
Z
|
||||
M 87.99 16.37
|
||||
Q 88.16 16.37 88.95 13.91
|
||||
A 1.31 1.31 0.0 0 0 88.90 12.99
|
||||
Q 88.43 11.92 88.00 11.92
|
||||
Q 87.58 11.92 87.10 12.98
|
||||
A 1.31 1.31 0.0 0 0 87.05 13.91
|
||||
Q 87.82 16.37 87.99 16.37
|
||||
Z
|
||||
M 83.74 18.25
|
||||
L 82.28 20.78
|
||||
A 1.66 1.66 0.0 0 0 82.07 21.79
|
||||
L 83.34 33.00
|
||||
A 0.20 0.20 0.0 0 0 83.71 33.07
|
||||
Q 87.68 25.64 84.59 18.31
|
||||
A 0.48 0.47 48.8 0 0 83.74 18.25
|
||||
Z
|
||||
M 92.26 18.20
|
||||
A 0.48 0.48 0.0 0 0 91.40 18.26
|
||||
Q 88.31 25.62 92.30 33.08
|
||||
A 0.20 0.20 0.0 0 0 92.67 33.01
|
||||
L 93.94 21.75
|
||||
A 1.67 1.66 -56.9 0 0 93.73 20.74
|
||||
L 92.26 18.20
|
||||
Z
|
||||
M 88.00 47.62
|
||||
Q 88.34 47.62 89.14 46.13
|
||||
Q 89.90 44.71 91.10 40.33
|
||||
A 1.08 1.06 -51.2 0 0 91.03 39.62
|
||||
L 88.14 33.79
|
||||
A 0.09 0.08 79.5 0 0 88.07 33.73
|
||||
Q 88.03 33.73 88.00 33.73
|
||||
Q 87.97 33.73 87.94 33.73
|
||||
A 0.09 0.08 -79.5 0 0 87.87 33.79
|
||||
L 84.97 39.62
|
||||
A 1.08 1.06 51.3 0 0 84.90 40.33
|
||||
Q 86.09 44.71 86.85 46.13
|
||||
Q 87.65 47.62 88.00 47.62
|
||||
Z"
|
||||
/>
|
||||
<path fill="#fbbf24" d="
|
||||
M 44.00 24.65
|
||||
Q 44.18 24.65 44.65 24.16
|
||||
C 52.89 15.80 65.71 21.08 65.96 32.76
|
||||
Q 66.04 36.51 65.99 92.81
|
||||
A 2.01 2.01 0.0 0 1 64.05 94.82
|
||||
L 63.97 94.82
|
||||
A 1.91 1.90 -0.9 0 1 62.00 92.92
|
||||
L 62.00 65.35
|
||||
A 1.24 1.20 -67.8 0 0 61.66 64.50
|
||||
Q 57.89 60.53 53.58 58.45
|
||||
A 0.75 0.75 0.0 0 0 52.70 58.61
|
||||
C 50.31 61.10 47.59 61.96 44.00 61.96
|
||||
C 40.41 61.96 37.69 61.10 35.30 58.61
|
||||
A 0.75 0.75 0.0 0 0 34.42 58.45
|
||||
Q 30.11 60.53 26.34 64.50
|
||||
A 1.24 1.20 67.8 0 0 26.00 65.35
|
||||
L 26.00 92.92
|
||||
A 1.91 1.90 0.9 0 1 24.03 94.82
|
||||
L 23.95 94.82
|
||||
A 2.01 2.01 0.0 0 1 22.01 92.81
|
||||
Q 21.96 36.51 22.04 32.76
|
||||
C 22.28 21.08 35.10 15.80 43.34 24.16
|
||||
Q 43.82 24.65 44.00 24.65
|
||||
Z
|
||||
M 44.00 57.96
|
||||
C 46.12 57.96 49.49 57.46 50.07 55.04
|
||||
Q 50.40 53.66 51.73 53.46
|
||||
A 1.97 1.91 -38.5 0 1 52.66 53.55
|
||||
Q 57.64 55.37 61.44 58.95
|
||||
A 0.32 0.32 0.0 0 0 61.98 58.71
|
||||
Q 62.04 40.89 61.97 32.78
|
||||
C 61.91 25.07 54.00 21.48 47.99 26.16
|
||||
C 46.66 27.20 45.76 29.97 43.99 29.97
|
||||
C 42.23 29.97 41.33 27.20 39.99 26.16
|
||||
C 33.98 21.49 26.07 25.08 26.02 32.79
|
||||
Q 25.95 40.90 26.02 58.72
|
||||
A 0.32 0.32 0.0 0 0 26.56 58.96
|
||||
Q 30.36 55.38 35.34 53.56
|
||||
A 1.97 1.91 38.5 0 1 36.27 53.46
|
||||
Q 37.60 53.66 37.93 55.04
|
||||
C 38.51 57.46 41.89 57.96 44.00 57.96
|
||||
Z"
|
||||
/>
|
||||
<rect fill="#fbbf24" x="37.07" y="68.01" width="13.86" height="3.98" rx="1.92"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#fbbf24" d="
|
||||
M 35.01 33.16
|
||||
C 36.76 33.18 40.51 34.06 39.85 36.58
|
||||
A 1.92 1.90 21.6 0 1 37.08 37.76
|
||||
Q 35.98 37.17 34.97 37.16
|
||||
Q 33.95 37.14 32.84 37.72
|
||||
A 1.92 1.90 -20.3 0 1 30.09 36.48
|
||||
C 29.49 33.94 33.26 33.14 35.01 33.16
|
||||
Z"
|
||||
/>
|
||||
<path fill="#fbbf24" d="
|
||||
M 53.03 37.15
|
||||
Q 52.02 37.17 50.91 37.76
|
||||
A 1.92 1.90 -21.6 0 1 48.15 36.58
|
||||
C 47.49 34.06 51.24 33.17 52.99 33.15
|
||||
C 54.74 33.13 58.52 33.94 57.91 36.48
|
||||
A 1.92 1.90 20.4 0 1 55.16 37.72
|
||||
Q 54.05 37.14 53.03 37.15
|
||||
Z"
|
||||
/>
|
||||
<circle fill="#fbbf24" cx="34.75" cy="42.20" r="2.27"/>
|
||||
<circle fill="#fbbf24" cx="54.00" cy="42.00" r="1.96"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -0,0 +1,142 @@
|
||||
// CommandBlock — a copy-paste shell command / config snippet. QML port of
|
||||
// crates/qbz-ui/ui/primitives/CommandBlock.slint.
|
||||
//
|
||||
// Surface-elevated card (radiusSm, no pills — ADR-008) with an optional
|
||||
// caption, a monospace body that WRAPS, and a copy button that flips to a
|
||||
// green check for 2 s. READ-ONLY: nothing here is ever executed. The height is
|
||||
// content-sized, so a multi-line command grows the block; callers place it in
|
||||
// a Column and never set a fixed height.
|
||||
//
|
||||
// THE CLIPBOARD. QML has no clipboard API of its own and this port has no
|
||||
// clipboard invokable, so the copy rides an off-screen TextEdit's own
|
||||
// `copy()` — the same carrier SandboxSettings.qml already uses. It is
|
||||
// duplicated there rather than shared because that file's block is a
|
||||
// different shape (a one-line row with a labelled "Copy" button); this one is
|
||||
// the wizard's, with a caption above and an icon button pinned to the top.
|
||||
//
|
||||
// WHY THE ICON IS `accent` AND NOT `success`. The check glyph after a copy is
|
||||
// green in the reference (a literal #3fae6a). `tintName: "success"` has NO qrc
|
||||
// bake directory, so whenever the runtime tint set is unreachable the glyph
|
||||
// would render as NOTHING at all rather than in a fallback colour
|
||||
// (theme/QbzIcon.qml, "the one way a bake still silently lies"). The port
|
||||
// already made this exact call once — WarningBanner.qml maps its `success`
|
||||
// variant's glyph to the `accent` tint for the same reason — so this follows
|
||||
// that precedent instead of inventing a one-glyph `success/` directory. The
|
||||
// TEXT colours in this modal are unaffected: `theme.success` is #3fae6a
|
||||
// verbatim, which is what the stepper dots and the read-back tick use.
|
||||
|
||||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string command: ""
|
||||
// Optional caption rendered above the command (e.g. what it does).
|
||||
property string caption: ""
|
||||
property bool justCopied: false
|
||||
|
||||
QbzTheme { id: theme }
|
||||
|
||||
width: parent ? parent.width : 0
|
||||
height: col.implicitHeight + 24
|
||||
radius: theme.radiusSm
|
||||
color: theme.surfaceElevated
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
|
||||
Column {
|
||||
id: col
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
visible: root.caption !== ""
|
||||
width: parent.width
|
||||
text: root.caption
|
||||
color: theme.textSecondary
|
||||
font.pixelSize: theme.fontLegal
|
||||
font.weight: theme.weightMedium
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: Math.max(cmdText.implicitHeight, 30)
|
||||
|
||||
// Monospace body — wraps, takes the width left by the button.
|
||||
// WrapAnywhere, not WordWrap: these are shell lines and long paths
|
||||
// with no spaces to break on, and WordWrap would push them out of
|
||||
// the card instead of folding them.
|
||||
Text {
|
||||
id: cmdText
|
||||
anchors.left: parent.left
|
||||
anchors.right: copyBtn.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.top: parent.top
|
||||
text: root.command
|
||||
color: theme.textPrimary
|
||||
font.family: "monospace"
|
||||
font.pixelSize: theme.fontLegal
|
||||
wrapMode: Text.WrapAnywhere
|
||||
}
|
||||
|
||||
// Off-screen carrier: TextEdit.copy() is the only clipboard write
|
||||
// QML offers without a C++ seam.
|
||||
TextEdit {
|
||||
id: carrier
|
||||
visible: false
|
||||
text: root.command
|
||||
}
|
||||
|
||||
// Copy button — fixed 30x30, pinned to the TOP of the row so a
|
||||
// tall command does not centre it halfway down the block.
|
||||
Rectangle {
|
||||
id: copyBtn
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
width: 30
|
||||
height: 30
|
||||
radius: theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: theme.borderSubtle
|
||||
color: copyTa.containsMouse ? theme.surfaceHover : theme.surfaceCard
|
||||
|
||||
QbzIcon {
|
||||
anchors.centerIn: parent
|
||||
width: 15
|
||||
height: 15
|
||||
name: root.justCopied ? "check" : "copy"
|
||||
tintName: root.justCopied
|
||||
? "accent"
|
||||
: (copyTa.containsMouse ? "textPrimary" : "muted")
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: copyTa
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
carrier.selectAll()
|
||||
carrier.copy()
|
||||
carrier.deselect()
|
||||
root.justCopied = true
|
||||
resetTimer.restart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resets the "copied" check after 2s (reference: `reset-timer`, 2s).
|
||||
Timer {
|
||||
id: resetTimer
|
||||
interval: 2000
|
||||
repeat: false
|
||||
onTriggered: root.justCopied = false
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,12 @@
|
||||
// - "Detected device limit" + its fallback disclosure (#638): the device-cap
|
||||
// probe (`device_cap.rs`) is Slint glue that was never ported, so there is
|
||||
// no measured value to show.
|
||||
// - "HiFi Wizard": the guided PipeWire setup (DacWizardActions) is a whole
|
||||
// modal flow, not a settings row.
|
||||
//
|
||||
// The HiFi Wizard IS shipped (2026-08-11): the last row of OUTPUT opens
|
||||
// settings/DacWizardModal.qml, mounted at the SettingsView root. Its logic
|
||||
// lives in `qbz-dac-wizard-core`, the frontend-agnostic crate extracted for
|
||||
// this port. Placement and backend gating diverge from the reference — see
|
||||
// the row itself.
|
||||
|
||||
import QtQuick
|
||||
import com.blitzfc.qbz
|
||||
@@ -125,6 +129,28 @@ Column {
|
||||
onSelected: function (i) { QbzBridge.settingsSelect("dsd-mode", i) }
|
||||
}
|
||||
}
|
||||
// HiFi Wizard — guided bit-perfect DAC setup. The LAST row of OUTPUT,
|
||||
// under "Output device", and shown for EVERY backend.
|
||||
//
|
||||
// TWO DIVERGENCES FROM THE REFERENCE, both owner rulings (2026-08-11):
|
||||
// it lives in OUTPUT, not at the end of BIT-PERFECT; and it is NOT gated
|
||||
// on `backend-is-pipewire` the way `AudioSettings.slint:282` gates it.
|
||||
// The reference hides it off PipeWire because the config it generates is
|
||||
// PipeWire/WirePlumber — but that is exactly who needs it: someone on ALSA
|
||||
// direct who wants to set their DAC up cannot reach the wizard that would
|
||||
// walk them through it. Do not "restore parity" by putting the gate back.
|
||||
//
|
||||
// Opening it resets the wizard and kicks the audio-stack probe off the UI
|
||||
// thread, so there is nothing to arm here.
|
||||
SettingRow {
|
||||
label: QbzSession.tr("HiFi Wizard", QbzSession.trRev)
|
||||
description: QbzSession.tr("Auto-detect your DACs and set up bit-perfect playback, step by step.", QbzSession.trRev)
|
||||
SettingsButton {
|
||||
iconName: "gandalf"
|
||||
text: QbzSession.tr("Open Wizard", QbzSession.trRev)
|
||||
onClicked: QbzDacWizard.open()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSpacer { }
|
||||
SettingsDivider { }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -328,4 +328,14 @@ Item {
|
||||
// panel that opens it. The reference mounts its counterpart at the
|
||||
// AppShell root for exactly the same reason (LibFolderEditModal.slint:5-8).
|
||||
LibFolderEditModal { doc: root.doc }
|
||||
|
||||
// The HiFi Wizard, opened from Settings > Audio. Mounted here for the same
|
||||
// reason as the two above — a modal inside the scrolled panel would be
|
||||
// sized by the content and ride the scroll — and it reads its OWN document
|
||||
// (QbzDacWizard), not the settings one, so it takes no `doc`.
|
||||
//
|
||||
// It fills the view rather than the window: Settings is the full content
|
||||
// area, and the reference is an overlay inside the app shell too, not a
|
||||
// separate window (DacWizardModal.slint:8-9).
|
||||
DacWizardModal { }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! QbzDacWizard — the HiFi Wizard's QML singleton.
|
||||
//!
|
||||
//! A singleton of its own rather than more surface on `QbzBridge`, for the same
|
||||
//! reason `QbzFolderEdit` is separate: the wizard is one self-contained modal
|
||||
//! with a document nobody else reads, and its ONE caller (the Settings > Audio
|
||||
//! row) does not want the settings document republished on every wizard poll —
|
||||
//! the read-back ticks every 1.5 s while the test plays.
|
||||
//!
|
||||
//! **No `#[qsignal]`.** Every outcome is a republish of `wizardJson`; the modal
|
||||
//! renders from the document and keeps no copy of anything Rust owns
|
||||
//! (`playlist_picker_bridge.rs:13`).
|
||||
//!
|
||||
//! The module is `qbz_dac_wizard_bridge` — a bridge module is never named after
|
||||
//! a workspace crate (E0659, and `qbz-dac-wizard` IS one). The QML type name
|
||||
//! comes from the QObject, so QML is unaffected.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cxx_qt::CxxQtThread;
|
||||
use cxx_qt::Threading as _;
|
||||
use cxx_qt_lib::QString;
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qbz_dac_wizard_bridge {
|
||||
extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
}
|
||||
|
||||
#[auto_cxx_name]
|
||||
extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
/// The whole wizard state (see `dac_wizard_qt::WizardDoc`). Parseable
|
||||
/// default so QML's `JSON.parse` never throws on the pre-publish frame.
|
||||
#[qproperty(QString, wizard_json)]
|
||||
|
||||
type QbzDacWizard = super::QbzDacWizardRust;
|
||||
|
||||
/// Registers this object's Qt-thread hop. Without it every publish
|
||||
/// from Rust is dropped on the floor, silently and forever: the modal
|
||||
/// mounts, no document ever arrives, and nothing is logged on either
|
||||
/// side.
|
||||
#[qinvokable]
|
||||
fn boot(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Settings > Audio > "Open Wizard": reset + seed the dropdowns, then
|
||||
/// probe the audio stack off the UI thread.
|
||||
#[qinvokable]
|
||||
fn open(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Scrim / header X / footer Close. Closing NEVER stops playback — the
|
||||
/// test step deliberately keeps playing underneath (reference §modal
|
||||
/// recipe: an overlay, not a window grab).
|
||||
#[qinvokable]
|
||||
fn close(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Check step: the user overrode the distribution (package manager).
|
||||
#[qinvokable]
|
||||
fn set_distro(self: Pin<&mut QbzDacWizard>, index: i32);
|
||||
|
||||
/// Check step: the user overrode the init system (service commands).
|
||||
#[qinvokable]
|
||||
fn set_init(self: Pin<&mut QbzDacWizard>, index: i32);
|
||||
|
||||
/// Entering the DACs step — enumerate sinks off the UI thread.
|
||||
#[qinvokable]
|
||||
fn run_detect(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Flip one enumerated candidate's checkbox.
|
||||
#[qinvokable]
|
||||
fn toggle_dac(self: Pin<&mut QbzDacWizard>, index: i32);
|
||||
|
||||
/// Validate a pasted `node.name` (the manual escape hatch).
|
||||
#[qinvokable]
|
||||
fn validate_manual(self: Pin<&mut QbzDacWizard>, text: QString);
|
||||
|
||||
/// Entering the review step — generate the per-DAC config snippets.
|
||||
#[qinvokable]
|
||||
fn gen_configs(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Collapse/expand one DAC's generated-config accordion.
|
||||
#[qinvokable]
|
||||
fn toggle_config(self: Pin<&mut QbzDacWizard>, index: i32);
|
||||
|
||||
/// Test step: resolve the four curated tracks and play them.
|
||||
#[qinvokable]
|
||||
fn start_test(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Test step: pause and drop the "playing" state.
|
||||
#[qinvokable]
|
||||
fn stop_test(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// Test step: jump straight to one of the four tracks.
|
||||
#[qinvokable]
|
||||
fn test_play_index(self: Pin<&mut QbzDacWizard>, index: i32);
|
||||
|
||||
/// Test step: run the read-back against the user's OWN queue.
|
||||
#[qinvokable]
|
||||
fn verify_own(self: Pin<&mut QbzDacWizard>);
|
||||
|
||||
/// One read-back tick (requested vs negotiated). Driven by the modal's
|
||||
/// 1.5 s Timer while the test plays.
|
||||
#[qinvokable]
|
||||
fn poll_test(self: Pin<&mut QbzDacWizard>);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for QbzDacWizard {}
|
||||
}
|
||||
|
||||
use qbz_dac_wizard_bridge::QbzDacWizard;
|
||||
|
||||
/// Rust side of the wizard bridge (plain storage; the real state lives in
|
||||
/// `dac_wizard_qt`).
|
||||
pub struct QbzDacWizardRust {
|
||||
wizard_json: QString,
|
||||
}
|
||||
|
||||
impl Default for QbzDacWizardRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wizard_json: QString::from(&crate::dac_wizard_qt::initial_json()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The UI hop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static QT_THREAD: OnceLock<CxxQtThread<QbzDacWizard>> = OnceLock::new();
|
||||
|
||||
/// Queue a wizard-document publish onto the Qt event loop (no-op before
|
||||
/// `boot()` registers the thread).
|
||||
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzDacWizard>) + Send + 'static) {
|
||||
if let Some(thread) = QT_THREAD.get() {
|
||||
let _ = thread.queue(f);
|
||||
}
|
||||
}
|
||||
|
||||
impl qbz_dac_wizard_bridge::QbzDacWizard {
|
||||
pub fn boot(self: Pin<&mut Self>) {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] dac wizard Qt thread already registered");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::open();
|
||||
}
|
||||
|
||||
pub fn close(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::close();
|
||||
}
|
||||
|
||||
pub fn set_distro(self: Pin<&mut Self>, index: i32) {
|
||||
crate::dac_wizard_qt::set_distro(index);
|
||||
}
|
||||
|
||||
pub fn set_init(self: Pin<&mut Self>, index: i32) {
|
||||
crate::dac_wizard_qt::set_init(index);
|
||||
}
|
||||
|
||||
pub fn run_detect(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::run_detect();
|
||||
}
|
||||
|
||||
pub fn toggle_dac(self: Pin<&mut Self>, index: i32) {
|
||||
crate::dac_wizard_qt::toggle_dac(index);
|
||||
}
|
||||
|
||||
pub fn validate_manual(self: Pin<&mut Self>, text: QString) {
|
||||
crate::dac_wizard_qt::validate_manual(&text.to_string());
|
||||
}
|
||||
|
||||
pub fn gen_configs(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::gen_configs();
|
||||
}
|
||||
|
||||
pub fn toggle_config(self: Pin<&mut Self>, index: i32) {
|
||||
crate::dac_wizard_qt::toggle_config(index);
|
||||
}
|
||||
|
||||
pub fn start_test(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::start_test();
|
||||
}
|
||||
|
||||
pub fn stop_test(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::stop_test();
|
||||
}
|
||||
|
||||
pub fn test_play_index(self: Pin<&mut Self>, index: i32) {
|
||||
crate::dac_wizard_qt::test_play_index(index);
|
||||
}
|
||||
|
||||
pub fn verify_own(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::verify_own();
|
||||
}
|
||||
|
||||
pub fn poll_test(self: Pin<&mut Self>) {
|
||||
crate::dac_wizard_qt::poll_test();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! HiFi Wizard (DAC setup) — the Qt controller.
|
||||
//!
|
||||
//! Port of `crates/qbz/src/main.rs:21613-21845` (the Slint glue) + the state
|
||||
//! half of `primitives/DacWizardModal.slint`. Every COMPUTATION lives in
|
||||
//! `qbz-dac-wizard-core`, which the Slint adapter also uses — the wizard emits
|
||||
//! PipeWire/WirePlumber snippets the user pastes into their own system, so a
|
||||
//! second implementation of that logic is the one divergence this port must
|
||||
//! never create (ADR-006; the crate split landed with this file).
|
||||
//!
|
||||
//! READ-ONLY, like the reference: this never writes a system file and never
|
||||
//! runs a command. It probes, it formats text, and on the test step it plays
|
||||
//! music through the normal queue path.
|
||||
//!
|
||||
//! ## What Rust owns and what QML owns
|
||||
//!
|
||||
//! Rust owns everything it COMPUTES (the probe verdict, the candidate list and
|
||||
//! its checkboxes, the manual node name + its validity, the generated configs
|
||||
//! and their accordion state, the read-back labels) plus `open` — because the
|
||||
//! Settings row opens the wizard and the reset + probe must be one atomic act.
|
||||
//!
|
||||
//! QML owns the pure NAVIGATION state: `step`, the welcome checkbox, the three
|
||||
//! review progress checkboxes and the manual-entry disclosure. That is exactly
|
||||
//! the reference's split — `DacWizardModal.slint` mutates those inline and
|
||||
//! never calls Rust for them (`:817 step -= 1`, `:853 step += 1`). They reset
|
||||
//! on `openSeq`, the counter this file bumps on every open.
|
||||
//!
|
||||
//! ## One document, one publish
|
||||
//!
|
||||
//! `QbzDacWizard.wizardJson` carries the whole state (the port's settings
|
||||
//! pattern). Rust mutates `STATE` and republishes; QML re-renders. No control
|
||||
//! keeps its own copy of anything Rust owns.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_dac_wizard_core as core;
|
||||
use qbz_models::Track;
|
||||
use serde::Serialize;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One enumerated sink plus the user's checkbox. `checked` is Rust-owned so
|
||||
/// `checked_dacs()` cannot disagree with what the user sees.
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct Candidate {
|
||||
id: String,
|
||||
description: String,
|
||||
bus: String,
|
||||
#[serde(rename = "isDefault")]
|
||||
is_default: bool,
|
||||
#[serde(rename = "looksLikeDac")]
|
||||
looks_like_dac: bool,
|
||||
checked: bool,
|
||||
#[serde(rename = "ratesLabel")]
|
||||
rates_label: String,
|
||||
}
|
||||
|
||||
/// One selected DAC's generated config + its accordion state.
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct ConfigRow {
|
||||
name: String,
|
||||
#[serde(rename = "nodeName")]
|
||||
node_name: String,
|
||||
#[serde(rename = "pipewireConf")]
|
||||
pipewire_conf: String,
|
||||
#[serde(rename = "pulseConf")]
|
||||
pulse_conf: String,
|
||||
#[serde(rename = "wireplumberConf")]
|
||||
wireplumber_conf: String,
|
||||
expanded: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct Remediation {
|
||||
caption: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct WizardDoc {
|
||||
open: bool,
|
||||
/// Bumped on every open — QML drops its navigation state when it changes
|
||||
/// (the `PlaylistImportModal.resetSeq` convention).
|
||||
#[serde(rename = "openSeq")]
|
||||
open_seq: u32,
|
||||
|
||||
// check
|
||||
#[serde(rename = "distroOptions")]
|
||||
distro_options: Vec<String>,
|
||||
#[serde(rename = "distroIndex")]
|
||||
distro_index: i32,
|
||||
#[serde(rename = "initOptions")]
|
||||
init_options: Vec<String>,
|
||||
#[serde(rename = "initIndex")]
|
||||
init_index: i32,
|
||||
#[serde(rename = "healthOk")]
|
||||
health_ok: bool,
|
||||
#[serde(rename = "healthSummary")]
|
||||
health_summary: String,
|
||||
remediations: Vec<Remediation>,
|
||||
sandboxed: bool,
|
||||
#[serde(rename = "sandboxName")]
|
||||
sandbox_name: String,
|
||||
|
||||
// select-dacs
|
||||
candidates: Vec<Candidate>,
|
||||
detecting: bool,
|
||||
#[serde(rename = "hasEnumeration")]
|
||||
has_enumeration: bool,
|
||||
#[serde(rename = "anyDacSelected")]
|
||||
any_dac_selected: bool,
|
||||
#[serde(rename = "manualNodeName")]
|
||||
manual_node_name: String,
|
||||
#[serde(rename = "manualValid")]
|
||||
manual_valid: bool,
|
||||
#[serde(rename = "manualDacType")]
|
||||
manual_dac_type: String,
|
||||
|
||||
// test
|
||||
#[serde(rename = "testRequestedLabel")]
|
||||
test_requested_label: String,
|
||||
#[serde(rename = "testNegotiatedLabel")]
|
||||
test_negotiated_label: String,
|
||||
#[serde(rename = "testRateMatched")]
|
||||
test_rate_matched: bool,
|
||||
#[serde(rename = "testPlaying")]
|
||||
test_playing: bool,
|
||||
/// The four curated test tracks, PRE-FORMATTED here so QML never builds a
|
||||
/// label (the port's rule; also keeps the strings in one catalog).
|
||||
#[serde(rename = "testTracks")]
|
||||
test_tracks: Vec<String>,
|
||||
|
||||
// review + done
|
||||
#[serde(rename = "dacConfigs")]
|
||||
dac_configs: Vec<ConfigRow>,
|
||||
#[serde(rename = "backupCmd")]
|
||||
backup_cmd: String,
|
||||
#[serde(rename = "restartCmd")]
|
||||
restart_cmd: String,
|
||||
#[serde(rename = "createdPaths")]
|
||||
created_paths: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everything the wizard owns, including the pieces that are NOT in the
|
||||
/// document: the cached probe (so a distro override recomputes without
|
||||
/// re-shelling) and the resolved test tracks (so the user can jump between
|
||||
/// them).
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
doc: WizardDoc,
|
||||
last_health: Option<qbz_audio::AudioStackHealth>,
|
||||
test_tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<Option<State>> = Mutex::new(None);
|
||||
|
||||
fn with_state<R>(f: impl FnOnce(&mut State) -> R) -> R {
|
||||
let mut guard = STATE.lock().unwrap();
|
||||
f(guard.get_or_insert_with(State::default))
|
||||
}
|
||||
|
||||
/// Serialize + hand the document to the Qt thread.
|
||||
fn publish() {
|
||||
let json = with_state(|s| serde_json::to_string(&s.doc).unwrap_or_else(|_| "{}".to_string()));
|
||||
crate::dac_wizard_bridge::ui(move |mut b| {
|
||||
b.as_mut().set_wizard_json(cxx_qt_lib::QString::from(&json));
|
||||
});
|
||||
}
|
||||
|
||||
/// The document as QML first sees it — closed, and parseable so a binding that
|
||||
/// reads `doc.open` on frame 1 cannot throw.
|
||||
pub fn initial_json() -> String {
|
||||
serde_json::to_string(&WizardDoc {
|
||||
has_enumeration: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_or_else(|_| "{\"open\":false}".to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Open / close
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Settings > Audio > "Open Wizard". Resets the whole wizard, seeds the two
|
||||
/// dropdowns from auto-detection, shows the "checking…" placeholder, then runs
|
||||
/// the (shelling) health probe off the UI thread.
|
||||
///
|
||||
/// 1:1 with `dac_wizard::open_immediate` + the `spawn_blocking` probe that
|
||||
/// follows it in the reference's `on_open`.
|
||||
pub fn open() {
|
||||
with_state(|s| {
|
||||
let seq = s.doc.open_seq.wrapping_add(1);
|
||||
let sandbox = core::sandbox_name();
|
||||
s.last_health = None;
|
||||
s.test_tracks.clear();
|
||||
s.doc = WizardDoc {
|
||||
open: true,
|
||||
open_seq: seq,
|
||||
distro_options: core::distro_options(),
|
||||
distro_index: core::detected_distro_index(),
|
||||
init_options: core::init_options(),
|
||||
init_index: core::detected_init_index(),
|
||||
sandboxed: !sandbox.is_empty(),
|
||||
sandbox_name: sandbox.to_string(),
|
||||
health_summary: qbz_i18n::t("Checking your audio stack…"),
|
||||
has_enumeration: true,
|
||||
test_tracks: vec![
|
||||
qbz_i18n::t("16-bit / 44.1 kHz — George Harrison · My Sweet Lord"),
|
||||
qbz_i18n::t("24-bit / 44.1 kHz — Billie Eilish · LUNCH"),
|
||||
qbz_i18n::t("24-bit / 96 kHz — Iron Maiden · Stratego"),
|
||||
qbz_i18n::t("24-bit / 192 kHz — Toto · Africa"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
});
|
||||
publish();
|
||||
log::info!("[qbz-qt] dac wizard opened (probing the audio stack)");
|
||||
|
||||
// The probe shells out (systemctl, aplay, pw-dump); never on the UI thread.
|
||||
crate::spawn(async move {
|
||||
let health = tokio::task::spawn_blocking(core::probe_health)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] dac wizard: health probe panicked: {e}");
|
||||
qbz_audio::AudioStackHealth {
|
||||
wireplumber_active: false,
|
||||
has_pw_dump: false,
|
||||
cpal_sees_pipewire: false,
|
||||
has_pactl: false,
|
||||
any_devices: false,
|
||||
}
|
||||
});
|
||||
with_state(|s| s.last_health = Some(health));
|
||||
recompute_check();
|
||||
let (ok, n) = with_state(|s| (s.doc.health_ok, s.doc.remediations.len()));
|
||||
log::info!("[qbz-qt] dac wizard health: ok={ok} remediations={n}");
|
||||
});
|
||||
}
|
||||
|
||||
/// Close. Leaves the document otherwise intact — reopening resets it anyway,
|
||||
/// and a close mid-test must not silently stop the music (the reference's
|
||||
/// scrim/X close nothing but the overlay).
|
||||
pub fn close() {
|
||||
with_state(|s| s.doc.open = false);
|
||||
publish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// User overrode the distro (package manager) — recompute the commands.
|
||||
pub fn set_distro(index: i32) {
|
||||
with_state(|s| s.doc.distro_index = index);
|
||||
recompute_check();
|
||||
}
|
||||
|
||||
/// User overrode the init system (service commands) — recompute.
|
||||
pub fn set_init(index: i32) {
|
||||
with_state(|s| s.doc.init_index = index);
|
||||
recompute_check();
|
||||
// The review step's step-3 command is init-aware too; keep it honest if the
|
||||
// user goes back and changes the init AFTER generating the configs.
|
||||
let restart = with_state(|s| core::restart_command_for(s.doc.init_index));
|
||||
let changed = with_state(|s| {
|
||||
if s.doc.restart_cmd.is_empty() || s.doc.restart_cmd == restart {
|
||||
false
|
||||
} else {
|
||||
s.doc.restart_cmd = restart;
|
||||
true
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
publish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the check step from the cached probe + the current dropdowns. Uses
|
||||
/// the cache so a dropdown change never re-shells (reference `recompute`).
|
||||
fn recompute_check() {
|
||||
with_state(|s| {
|
||||
let health = s.last_health.unwrap_or_else(core::probe_health);
|
||||
let view = core::check_view(health, s.doc.distro_index, s.doc.init_index, s.doc.sandboxed);
|
||||
s.doc.health_ok = view.health_ok;
|
||||
s.doc.health_summary = view.summary;
|
||||
s.doc.remediations = view
|
||||
.remediations
|
||||
.into_iter()
|
||||
.map(|r| Remediation {
|
||||
caption: r.caption,
|
||||
command: r.command,
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
publish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Select-DACs step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Entering the DACs step: show "detecting…", then enumerate off the UI thread.
|
||||
/// Pre-selects the likely DACs; an empty enumeration flips `hasEnumeration` off
|
||||
/// so the manual escape hatch opens unconditionally.
|
||||
pub fn run_detect() {
|
||||
with_state(|s| s.doc.detecting = true);
|
||||
publish();
|
||||
|
||||
crate::spawn(async move {
|
||||
let data = tokio::task::spawn_blocking(core::detect_blocking)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] dac wizard: enumeration panicked: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
with_state(|s| {
|
||||
let rows: Vec<Candidate> = data
|
||||
.iter()
|
||||
.map(|d| Candidate {
|
||||
id: d.id.clone(),
|
||||
description: d.description.clone(),
|
||||
bus: d.bus.clone(),
|
||||
is_default: d.is_default,
|
||||
looks_like_dac: d.looks_like_dac,
|
||||
checked: d.looks_like_dac,
|
||||
rates_label: d.rates_label.clone(),
|
||||
})
|
||||
.collect();
|
||||
s.doc.any_dac_selected = rows.iter().any(|r| r.checked);
|
||||
s.doc.has_enumeration = !data.is_empty();
|
||||
s.doc.candidates = rows;
|
||||
s.doc.detecting = false;
|
||||
});
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/// Flip one candidate's checkbox + recompute the Next gate.
|
||||
pub fn toggle_dac(index: i32) {
|
||||
with_state(|s| {
|
||||
if let Some(row) = s.doc.candidates.get_mut(index.max(0) as usize) {
|
||||
row.checked = !row.checked;
|
||||
}
|
||||
s.doc.any_dac_selected = s.doc.candidates.iter().any(|r| r.checked);
|
||||
});
|
||||
publish();
|
||||
}
|
||||
|
||||
/// Validate a manually-pasted `node.name` (escape hatch).
|
||||
///
|
||||
/// The typed text is stored here because `checked_dacs()` reads it, but QML
|
||||
/// keeps its own field text and never re-reads this back — republishing into a
|
||||
/// focused TextInput is how a cursor ends up jumping to the end mid-word.
|
||||
pub fn validate_manual(text: &str) {
|
||||
with_state(|s| {
|
||||
s.doc.manual_node_name = text.to_string();
|
||||
s.doc.manual_valid = core::validate_node_name(text);
|
||||
s.doc.manual_dac_type = core::detect_dac_type(text).to_string();
|
||||
});
|
||||
publish();
|
||||
}
|
||||
|
||||
/// (node_name, display_name) for every checked candidate, or a valid manual one.
|
||||
fn checked_dacs() -> Vec<(String, String)> {
|
||||
with_state(|s| {
|
||||
let mut out: Vec<(String, String)> = s
|
||||
.doc
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|c| c.checked)
|
||||
.map(|c| (c.id.clone(), c.description.clone()))
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
let manual = s.doc.manual_node_name.trim().to_string();
|
||||
if !manual.is_empty() && s.doc.manual_valid {
|
||||
out.push((manual.clone(), manual));
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Review step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Entering the review step: re-probe rates and build the three snippets per
|
||||
/// selected DAC, off the UI thread. One DAC → expanded; several → collapsed
|
||||
/// accordions (reference `apply_configs`).
|
||||
pub fn gen_configs() {
|
||||
let dacs = checked_dacs();
|
||||
crate::spawn(async move {
|
||||
let data = tokio::task::spawn_blocking(move || core::gen_configs_blocking(dacs))
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("[qbz-qt] dac wizard: config generation panicked: {e}");
|
||||
Vec::new()
|
||||
});
|
||||
with_state(|s| {
|
||||
let single = data.len() == 1;
|
||||
s.doc.created_paths = core::created_paths(&data);
|
||||
s.doc.dac_configs = data
|
||||
.into_iter()
|
||||
.map(|d| ConfigRow {
|
||||
name: d.name,
|
||||
node_name: d.node_name,
|
||||
pipewire_conf: d.pipewire_conf,
|
||||
pulse_conf: d.pulse_conf,
|
||||
wireplumber_conf: d.wireplumber_conf,
|
||||
expanded: single,
|
||||
})
|
||||
.collect();
|
||||
s.doc.backup_cmd = core::BACKUP_CMD.to_string();
|
||||
s.doc.restart_cmd = core::restart_command_for(s.doc.init_index);
|
||||
});
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/// Collapse/expand one DAC's generated-config accordion.
|
||||
pub fn toggle_config(index: i32) {
|
||||
with_state(|s| {
|
||||
if let Some(row) = s.doc.dac_configs.get_mut(index.max(0) as usize) {
|
||||
row.expanded = !row.expanded;
|
||||
}
|
||||
});
|
||||
publish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test step
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Show the "playing" state. The read-back probes whichever DAC is actively
|
||||
/// playing (scan), so no node needs to be stashed.
|
||||
fn begin_test(s: &mut State) {
|
||||
s.doc.test_playing = true;
|
||||
s.doc.test_rate_matched = false;
|
||||
s.doc.test_requested_label = qbz_i18n::t("Starting…");
|
||||
s.doc.test_negotiated_label = String::new();
|
||||
}
|
||||
|
||||
/// "Play test": resolve the four curated tracks (id-hint first, then an
|
||||
/// "artist title" search when the id 404s on a pulled licence — never
|
||||
/// raw-id-only), stash them so the user can jump between them, and play.
|
||||
pub fn start_test() {
|
||||
with_state(begin_test);
|
||||
publish();
|
||||
|
||||
crate::spawn(async move {
|
||||
let runtime = crate::app();
|
||||
let mut tracks: Vec<Track> = Vec::new();
|
||||
for seed in core::TEST_SEEDS.iter() {
|
||||
let mut chosen = match runtime.core().get_track(seed.id_hint).await {
|
||||
Ok(t) if core::track_matches_seed(&t, seed) => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
if chosen.is_none() {
|
||||
let q = format!("{} {}", seed.artist, seed.title);
|
||||
if let Ok(page) = runtime.core().search_tracks(&q, 10, 0, None).await {
|
||||
chosen = page
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|t| core::track_matches_seed(t, seed));
|
||||
}
|
||||
}
|
||||
if let Some(t) = chosen {
|
||||
tracks.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
if tracks.is_empty() {
|
||||
with_state(|s| {
|
||||
s.doc.test_playing = false;
|
||||
s.doc.test_requested_label =
|
||||
qbz_i18n::t("Couldn't load the test tracks (offline?)");
|
||||
s.doc.test_negotiated_label = String::new();
|
||||
s.doc.test_rate_matched = false;
|
||||
});
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
with_state(|s| s.test_tracks = tracks.clone());
|
||||
play_from(tracks, 0).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Jump straight to one of the four tracks (skip the long waits) by re-setting
|
||||
/// the queue at that index through the working play path.
|
||||
pub fn test_play_index(index: i32) {
|
||||
crate::spawn(async move {
|
||||
let tracks = with_state(|s| s.test_tracks.clone());
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
let start = (index.max(0) as usize).min(tracks.len() - 1);
|
||||
play_from(tracks, start).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the queue from catalog tracks and start at `start`. A FLAT list, so no
|
||||
/// container context is stamped — 1:1 with the reference's
|
||||
/// `play_tracks_ctx(.., None)`.
|
||||
async fn play_from(tracks: Vec<Track>, start: usize) {
|
||||
let runtime = crate::app();
|
||||
let queue: Vec<qbz_models::QueueTrack> = tracks
|
||||
.iter()
|
||||
.map(crate::foryou_qt::to_queue_track)
|
||||
.collect();
|
||||
if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, start, false).await {
|
||||
log::warn!("[qbz-qt] dac wizard: test playback failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the test. Pauses (the reference does exactly this — it does not clear
|
||||
/// the queue, so the user keeps whatever was loaded).
|
||||
pub fn stop_test() {
|
||||
crate::spawn(async move {
|
||||
let _ = crate::app().core().pause();
|
||||
with_state(|s| s.doc.test_playing = false);
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/// "Use my current queue": start the read-back on the user's own music instead
|
||||
/// of the curated tracks. Guardrail — an empty queue gets a hint rather than a
|
||||
/// read-back that would sit forever on "Nothing playing".
|
||||
pub fn verify_own() {
|
||||
crate::spawn(async move {
|
||||
let runtime = crate::app();
|
||||
let (tracks, _) = runtime.core().get_all_queue_tracks().await;
|
||||
if tracks.is_empty() {
|
||||
with_state(|s| {
|
||||
s.doc.test_playing = false;
|
||||
s.doc.test_rate_matched = false;
|
||||
s.doc.test_negotiated_label = String::new();
|
||||
s.doc.test_requested_label = qbz_i18n::t(
|
||||
"Your queue is empty — add some tracks first, or press Play test.",
|
||||
);
|
||||
});
|
||||
publish();
|
||||
return;
|
||||
}
|
||||
let _ = runtime.core().resume();
|
||||
with_state(begin_test);
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
/// One poll of the read-back: what QBZ asked the device for vs what the DAC is
|
||||
/// really clocked at (N6). Driven by a QML `Timer` at 1.5 s while the test
|
||||
/// plays, exactly as the reference drives it.
|
||||
pub fn poll_test() {
|
||||
crate::spawn(async move {
|
||||
let runtime = crate::app();
|
||||
let player = runtime.core().player();
|
||||
let req_rate = player.state.get_sample_rate();
|
||||
let req_bits = player.state.get_bit_depth();
|
||||
// The ALSA probe reads /proc; keep it off the UI thread like the rest.
|
||||
let negotiated = tokio::task::spawn_blocking(qbz_audio::negotiated_active_rate)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let view = core::poll_view(req_rate, req_bits, negotiated);
|
||||
with_state(|s| {
|
||||
s.doc.test_requested_label = view.requested_label;
|
||||
s.doc.test_negotiated_label = view.negotiated_label;
|
||||
s.doc.test_rate_matched = view.rate_matched;
|
||||
});
|
||||
publish();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `STATE` is a process-global and cargo runs these in PARALLEL threads,
|
||||
/// so three tests that each seed and clear it raced and failed each other
|
||||
/// (caught by the gate, 2026-08-11). Every test that touches the wizard
|
||||
/// state takes this lock for its whole body.
|
||||
///
|
||||
/// `lock()` rather than `lock().unwrap()`: a test that panics while
|
||||
/// holding it would poison the mutex and turn one real failure into three,
|
||||
/// hiding which one is the actual defect.
|
||||
static TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*STATE.lock().unwrap() = None;
|
||||
g
|
||||
}
|
||||
|
||||
/// The pre-publish document must parse in QML AND must not put the manual
|
||||
/// escape hatch on screen before enumeration has ever run (`hasEnumeration`
|
||||
/// false is what forces it open).
|
||||
#[test]
|
||||
fn initial_document_is_closed_and_parseable() {
|
||||
let v: serde_json::Value = serde_json::from_str(&initial_json()).unwrap();
|
||||
assert_eq!(v["open"], serde_json::json!(false));
|
||||
assert_eq!(v["hasEnumeration"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
/// `checked_dacs` prefers the CHECKED candidates and only falls back to the
|
||||
/// manual field when nothing is checked — and never accepts an invalid one.
|
||||
#[test]
|
||||
fn checked_dacs_prefers_candidates_then_valid_manual() {
|
||||
let _g = guard();
|
||||
with_state(|s| {
|
||||
s.doc.candidates = vec![
|
||||
Candidate {
|
||||
id: "alsa_output.usb-a".into(),
|
||||
description: "DacMagic".into(),
|
||||
checked: true,
|
||||
..Default::default()
|
||||
},
|
||||
Candidate {
|
||||
id: "alsa_output.pci-b".into(),
|
||||
description: "Onboard".into(),
|
||||
checked: false,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
s.doc.manual_node_name = "alsa_output.usb-manual".into();
|
||||
s.doc.manual_valid = true;
|
||||
});
|
||||
let picked = checked_dacs();
|
||||
assert_eq!(picked.len(), 1);
|
||||
assert_eq!(picked[0].0, "alsa_output.usb-a");
|
||||
|
||||
// Nothing checked -> the valid manual entry.
|
||||
with_state(|s| s.doc.candidates.clear());
|
||||
let picked = checked_dacs();
|
||||
assert_eq!(picked.len(), 1);
|
||||
assert_eq!(picked[0].0, "alsa_output.usb-manual");
|
||||
|
||||
// An INVALID manual entry is not a DAC.
|
||||
with_state(|s| s.doc.manual_valid = false);
|
||||
assert!(checked_dacs().is_empty());
|
||||
}
|
||||
|
||||
/// Toggling a candidate must move the Next gate with it — the gate is what
|
||||
/// stops the user reaching a review step with nothing to generate.
|
||||
#[test]
|
||||
fn toggling_a_candidate_moves_the_next_gate() {
|
||||
let _g = guard();
|
||||
with_state(|s| {
|
||||
s.doc.candidates = vec![Candidate {
|
||||
id: "alsa_output.usb-a".into(),
|
||||
checked: false,
|
||||
..Default::default()
|
||||
}];
|
||||
s.doc.any_dac_selected = false;
|
||||
});
|
||||
toggle_dac(0);
|
||||
assert!(with_state(|s| s.doc.any_dac_selected));
|
||||
toggle_dac(0);
|
||||
assert!(!with_state(|s| s.doc.any_dac_selected));
|
||||
}
|
||||
|
||||
/// An out-of-range index from QML must not panic (a recycled delegate can
|
||||
/// fire one after the model shrank).
|
||||
#[test]
|
||||
fn out_of_range_toggles_are_ignored() {
|
||||
let _g = guard();
|
||||
toggle_dac(7);
|
||||
toggle_config(-3);
|
||||
assert!(with_state(|s| s.doc.candidates.is_empty()));
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,11 @@ const MASTERS: &[(&str, &str)] = &[
|
||||
("folder-open", include_str!("../qml/assets/icons/primary/folder-open.svg")),
|
||||
("folder-plus", include_str!("../qml/assets/icons/primary/folder-plus.svg")),
|
||||
("folder", include_str!("../qml/assets/icons/primary/folder.svg")),
|
||||
// The HiFi Wizard's header glyph. Unlike every other master this one is a
|
||||
// FILLED path set (`fill="#ffffff"`), not a stroked outline — the literal
|
||||
// substitution does not care, but a future "why is this file shaped
|
||||
// differently" reads better answered here.
|
||||
("gandalf", include_str!("../qml/assets/icons/primary/gandalf.svg")),
|
||||
("globe", include_str!("../qml/assets/icons/primary/globe.svg")),
|
||||
("grip-vertical", include_str!("../qml/assets/icons/primary/grip-vertical.svg")),
|
||||
("hard-drive", include_str!("../qml/assets/icons/primary/hard-drive.svg")),
|
||||
|
||||
@@ -72,6 +72,12 @@ mod playlist_edit_bridge;
|
||||
// manager, opened from two shell surfaces that outlive each other, and its
|
||||
// modal must survive the one that opened it (05 §5.8).
|
||||
mod playlist_import_bridge;
|
||||
// HiFi Wizard (DAC setup). Its own singleton rather than more surface on
|
||||
// QbzBridge: the wizard is one self-contained modal whose document nobody else
|
||||
// reads, and its read-back ticks every 1.5 s while the test plays — routing
|
||||
// that through the settings document would republish the whole Settings view
|
||||
// on every tick. Its controller half is `dac_wizard_qt` (a PLAIN module).
|
||||
mod dac_wizard_bridge;
|
||||
// Qobuz Connect (2026-08-01 contract §8, block B4-Rust): the QbzQConnect
|
||||
// singleton — the QML surface of the facade/sink controllers below
|
||||
// (qconnect_qt.rs / qconnect_event_sink_qt.rs).
|
||||
@@ -101,6 +107,13 @@ mod fav_cache_qt;
|
||||
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files.
|
||||
mod folder_edit_qt;
|
||||
mod folders_qt;
|
||||
// The HiFi Wizard controller. A plain module — it declares no
|
||||
// #[cxx_qt::bridge], so it must NOT appear in build.rs's rust_files. All of
|
||||
// its COMPUTATION lives in the shared `qbz-dac-wizard-core` crate, which the
|
||||
// Slint adapter uses too: the wizard emits PipeWire/WirePlumber snippets the
|
||||
// user pastes into their own system, so two implementations would be the one
|
||||
// divergence this port must never produce.
|
||||
mod dac_wizard_qt;
|
||||
mod foryou_qt;
|
||||
mod genre_filter_qt;
|
||||
mod home_qt;
|
||||
|
||||
@@ -3102,6 +3102,9 @@ msgstr "Playlist-Link wird geprüft..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "Ihr Audio-Stack wird geprüft…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "Die Testtitel konnten nicht geladen werden (offline?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Wählen Sie ein Coverbild"
|
||||
|
||||
|
||||
@@ -3102,6 +3102,9 @@ msgstr "Verificando el enlace de la lista de reproducción..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "Verificando tu sistema de audio…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "No se pudieron cargar las pistas de prueba (¿sin conexión?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Elige una imagen de portada"
|
||||
|
||||
|
||||
@@ -3102,6 +3102,9 @@ msgstr "Vérification du lien de la playlist..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "Vérification de votre pile audio…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "Impossible de charger les pistes de test (hors ligne ?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Choisissez une image de couverture"
|
||||
|
||||
|
||||
@@ -2959,6 +2959,9 @@ msgstr "プレイリストのリンクを確認中..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "オーディオスタックを確認中…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "テストトラックを読み込めませんでした(オフライン?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "カバー画像を選択"
|
||||
|
||||
|
||||
@@ -3098,6 +3098,9 @@ msgstr "Afspeellijstlink controleren..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "Je audiostack controleren…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "Kan de testtracks niet laden (offline?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Kies een hoesafbeelding"
|
||||
|
||||
|
||||
@@ -3102,6 +3102,9 @@ msgstr "A verificar a ligação da lista de reprodução..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "A verificar a sua pilha de áudio…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "Não foi possível carregar as faixas de teste (offline?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Escolha uma imagem de capa"
|
||||
|
||||
|
||||
@@ -2983,6 +2983,9 @@ msgstr "Проверка ссылки на плейлист..."
|
||||
msgid "Checking your audio stack…"
|
||||
msgstr "Проверка аудиосистемы…"
|
||||
|
||||
msgid "Couldn't load the test tracks (offline?)"
|
||||
msgstr "Не удалось загрузить тестовые треки (нет сети?)"
|
||||
|
||||
msgid "Choose a cover image"
|
||||
msgstr "Выберите изображение обложки"
|
||||
|
||||
|
||||
Reference in new issue
Block a user