From 9bb392c262663132b532c31d9ecd49ba001af5c2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:09:35 -0600 Subject: [PATCH] =?UTF-8?q?feat(slint):=20DAC=20wizard=20shell=20=E2=80=94?= =?UTF-8?q?=206-step=20modal=20+=20state=20+=20trigger=20(Slice=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DacWizardState/DacWizardActions/DacCandidateRow in state.slint (re-exported in app.slint). DacWizardModal: scrim + 620px card + dot/label stepper rail + Flickable body with if-step blocks + footer Back/Close/primary. Navigation is inline state mutation (no Rust yet); the Actions callbacks (copy-command/ run-detect/query-caps/start-test) are declared for Slices 6-9. welcome step is real (disclaimer + accept checkbox gating Start); steps 1-5 are titled placeholders. Mounted in AppShell; "Open Wizard" 160x34 trigger added to Audio settings, gated on PipeWire. cc --- crates/qbz-slint/ui/app.slint | 2 +- .../ui/primitives/DacWizardModal.slint | 273 ++++++++++++++++++ .../qbz-slint/ui/settings/AudioSettings.slint | 33 ++- crates/qbz-slint/ui/shell/AppShell.slint | 5 + crates/qbz-slint/ui/state.slint | 55 ++++ 5 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 crates/qbz-slint/ui/primitives/DacWizardModal.slint diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index bcf97d518..982dbbf4a 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -19,7 +19,7 @@ import { Toast } from "primitives/Toast.slint"; import { ShellState } from "state.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState } from "state.slint"; +export { HomeState, HomeActions, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, OfflineState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, VisualizerState, ImmersiveState } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/primitives/DacWizardModal.slint b/crates/qbz-slint/ui/primitives/DacWizardModal.slint new file mode 100644 index 000000000..bf8059cbe --- /dev/null +++ b/crates/qbz-slint/ui/primitives/DacWizardModal.slint @@ -0,0 +1,273 @@ +// HiFi Wizard (DAC setup) — Slint shell. Slice 5: the 6-step modal skeleton +// (scrim + card + stepper + footer nav). Navigation is handled inline by +// mutating DacWizardState (open/close/next/back need no Rust). Step bodies are +// filled by later slices: welcome (here) · check (S6) · select-dacs (S7) · +// review (S10) · test (S9) · done (S10). The wizard stays READ-ONLY — it never +// writes a system audio file. +// +// Modal recipe mirrors PlaylistImportModal: overlay Rectangle (not a window +// grab), so playback keeps running underneath while the test step plays. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { DacWizardState } from "../state.slint"; +import { QbzIcon } from "QbzIcon.slint"; +import { QbzPrimaryButton } from "QbzPrimaryButton.slint"; +import { SecondaryButton } from "SecondaryButton.slint"; +import { QbzCheckbox } from "QbzCheckbox.slint"; + +// Temporary titled placeholder for steps not yet built. Removed as each step +// lands (Slices 6/7/9/10). +component StepPlaceholder inherits VerticalLayout { + in property title; + in property note; + spacing: 10px; + Text { + text: root.title; + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + wrap: word-wrap; + } + Text { + text: root.note; + color: Theme.text-muted; + font-size: Typography.body; + wrap: word-wrap; + } +} + +export component DacWizardModal inherits Rectangle { + visible: DacWizardState.open; + + // Step labels for the header rail (index = DacWizardState.step). + property <[string]> steps: [ + @tr("Welcome"), + @tr("Check"), + @tr("DACs"), + @tr("Configure"), + @tr("Test"), + @tr("Done"), + ]; + + if DacWizardState.open: Rectangle { + background: #000000bf; + // Scrim — click outside closes. + TouchArea { + mouse-cursor: default; + clicked => { + DacWizardState.open = false; + } + } + + card := Rectangle { + width: Math.min(root.width - 80px, 620px); + height: Math.min(Math.max(500px, body.preferred-height + 210px), root.height * 0.9); + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + border-radius: Radius.lg; + background: Theme.surface-card; + border-width: 1px; + border-color: Theme.border-subtle; + drop-shadow-blur: 32px; + drop-shadow-color: #00000080; + // Swallow clicks so they never reach the scrim. + TouchArea { } + + VerticalLayout { + padding: 0px; + spacing: 0px; + + // Header: wand icon + title + close-x. + HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 20px; + padding-bottom: 20px; + spacing: 10px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/circle-check-big.svg"); + width: 24px; + height: 24px; + tint: #3fae6a; + } + } + Text { + text: @tr("HiFi Wizard — DAC Setup"); + color: Theme.text-primary; + font-size: Typography.section; + font-weight: Typography.semibold; + horizontal-stretch: 1; + vertical-alignment: center; + } + close-x := TouchArea { + width: 28px; + height: 28px; + mouse-cursor: pointer; + clicked => { + DacWizardState.open = false; + } + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 17px; + height: 17px; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted; + } + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Stepper rail — dots + labels, colored by progress. + HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 14px; + padding-bottom: 14px; + spacing: 6px; + for label[idx] in root.steps: HorizontalLayout { + spacing: 6px; + horizontal-stretch: 1; + VerticalLayout { + alignment: center; + Rectangle { + width: 10px; + height: 10px; + border-radius: 5px; + background: idx <= DacWizardState.step ? #3fae6a : Theme.border-muted; + } + } + Text { + text: label; + color: idx == DacWizardState.step ? Theme.text-primary + : (idx < DacWizardState.step ? Theme.text-secondary : Theme.text-muted); + font-size: Typography.legal; + font-weight: idx == DacWizardState.step ? Typography.semibold : Typography.regular; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + } + } + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Body — scrolls when a step outgrows the card. + Flickable { + vertical-stretch: 1; + viewport-width: self.width; + viewport-height: body.preferred-height; + + body := VerticalLayout { + padding: 24px; + spacing: 16px; + alignment: start; + + // ── Step 0: welcome ────────────────────────────── + if DacWizardState.step == 0: VerticalLayout { + spacing: 14px; + Text { + text: @tr("Guided DAC Setup"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + } + Text { + text: @tr("This wizard helps you configure your DAC for bit-perfect playback on Linux. QBZ never changes your system automatically — it auto-detects your DACs, shows you the exact commands and config to copy, and lets you test playback right here."); + color: Theme.text-secondary; + font-size: Typography.body; + wrap: word-wrap; + } + Text { + text: @tr("You are responsible for your own backups. This is a helper, not a system recovery tool."); + color: Theme.text-muted; + font-size: Typography.legal; + wrap: word-wrap; + } + HorizontalLayout { + spacing: 10px; + alignment: start; + // QbzCheckbox self-flips `checked` and emits + // toggled(); the two-way bind syncs the state, so + // no toggled handler (it would double-flip). + QbzCheckbox { + checked <=> DacWizardState.welcome-confirmed; + } + Text { + text: @tr("I understand and accept these terms"); + color: Theme.text-secondary; + font-size: Typography.body; + vertical-alignment: center; + } + } + } + + // ── Steps 1..5: placeholders (filled by later slices) + if DacWizardState.step == 1: StepPlaceholder { + title: @tr("Check Audio Stack"); + note: @tr("Auto-probes PipeWire / WirePlumber and surfaces only the packages you are missing (Slice 6)."); + } + if DacWizardState.step == 2: StepPlaceholder { + title: @tr("Which DACs do you want to configure?"); + note: @tr("Auto-detected sink list with checkboxes — no manual node.name paste (Slice 7)."); + } + if DacWizardState.step == 3: StepPlaceholder { + title: @tr("Review & Apply"); + note: @tr("Backup, per-DAC sample rates, and the copy-paste config files (Slice 10)."); + } + if DacWizardState.step == 4: StepPlaceholder { + title: @tr("Test Playback"); + note: @tr("Plays tracks at 16/44.1, 24/44.1, 24/96 and 24/192 and reads the DAC's real negotiated rate (Slice 9)."); + } + if DacWizardState.step == 5: StepPlaceholder { + title: @tr("All set"); + note: @tr("Summary and the config paths you created (Slice 10)."); + } + } + } + + Rectangle { height: 1px; background: Theme.border-subtle; } + + // Footer: Back · Close · primary (Start/Next/Finish). + HorizontalLayout { + padding-left: 24px; + padding-right: 24px; + padding-top: 16px; + padding-bottom: 20px; + spacing: 12px; + // Back — hidden on the first step. + if DacWizardState.step > 0: SecondaryButton { + label: @tr("Back"); + clicked => { + DacWizardState.step -= 1; + } + } + Rectangle { horizontal-stretch: 1; } + SecondaryButton { + label: @tr("Close"); + clicked => { + DacWizardState.open = false; + } + } + QbzPrimaryButton { + height: 34px; + label: DacWizardState.step == 0 ? @tr("Start") + : (DacWizardState.step == 5 ? @tr("Finish") : @tr("Next")); + // Step 0 gated on the accept checkbox; later gates added per slice. + enabled: DacWizardState.step == 0 ? DacWizardState.welcome-confirmed : true; + clicked => { + if (DacWizardState.step == 5) { + DacWizardState.open = false; + } else { + DacWizardState.step += 1; + } + } + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/settings/AudioSettings.slint b/crates/qbz-slint/ui/settings/AudioSettings.slint index cedbbb0e9..df9b1266a 100644 --- a/crates/qbz-slint/ui/settings/AudioSettings.slint +++ b/crates/qbz-slint/ui/settings/AudioSettings.slint @@ -6,7 +6,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; -import { SettingsState } from "../state.slint"; +import { SettingsState, DacWizardState } from "../state.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; @@ -269,6 +269,37 @@ export component AudioSettings inherits VerticalLayout { } } + // HiFi Wizard — guided DAC setup (PipeWire only, mirrors the Tauri gate). + if SettingsState.backend-is-pipewire: GroupHeader { text: @tr("DAC SETUP"); } + if SettingsState.backend-is-pipewire: SettingRow { + label: @tr("HiFi Wizard"); + description: @tr("Auto-detect your DACs and set up bit-perfect playback, step by step."); + wizard-btn := Rectangle { + width: 160px; + height: 34px; + border-radius: Radius.sm; + border-width: 1px; + border-color: Theme.border-subtle; + background: wizard-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + Text { + text: @tr("Open Wizard"); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + horizontal-alignment: center; + vertical-alignment: center; + } + wizard-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + DacWizardState.open = true; + DacWizardState.step = 0; + DacWizardState.welcome-confirmed = false; + } + } + } + } + Rectangle { height: 20px; } // Reset — restores Audio + Playback defaults. diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index 1fd15110b..4d3074061 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -31,6 +31,7 @@ import { FolderEditModal } from "../primitives/FolderEditModal.slint"; import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint"; import { TagEditorModal } from "../primitives/TagEditorModal.slint"; import { PlaylistImportModal } from "../primitives/PlaylistImportModal.slint"; +import { DacWizardModal } from "../primitives/DacWizardModal.slint"; import { TrackInfoModal } from "../album/TrackInfoModal.slint"; import { AlbumCreditsModal } from "../album/AlbumCreditsModal.slint"; import { ImmersiveView } from "../immersive/ImmersiveView.slint"; @@ -490,6 +491,10 @@ export component AppShell inherits Rectangle { // `open`), keeping QconnectDevModal topmost. PlaylistImportModal { } + // HiFi Wizard (DAC setup) — opened from Audio settings (PipeWire only). + // Overlay modal; playback keeps running underneath so the test step works. + DacWizardModal { } + // Track Info + Album Info (Credits/Review) modals — fed by crate::info_modals // through TrackInfoState / AlbumInfoState. Conditional-mount on their own // `open`; stacked above the feature modals (ADR-009/010). diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 189522788..1ca7b8502 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -3851,3 +3851,58 @@ export global PlaylistImportActions { // Step B: import_public_playlist(...) with rename + folder choice. callback execute(); } + +// ── HiFi Wizard (DAC setup) ───────────────────────────────────────────── +// Native-Slint port + redesign of the Tauri DACSetupWizard. 6-step adaptive +// flow: welcome → check → select-dacs → review-and-apply → test → done. +// READ-ONLY by contract: the wizard never writes a system audio config — it +// auto-detects DACs (pw-dump, Slice 0), generates copy-paste config, and runs +// an in-app playback test reading the real negotiated rate (N6, Slice 8b). + +// One enumerated sink the user can pick to configure. +export struct DacCandidateRow { + id: string, // PipeWire node.name (the value the user used to paste) + description: string, // pretty name, e.g. "DacMagic Plus Analog Stereo" + bus: string, // "usb" | "pci" | "bluetooth" | "" + is-default: bool, // current system default sink + looks-like-dac: bool, // hardware && bus ∈ {usb,pci} → pre-selected + checked: bool, // user selection + rates-label: string, // pre-filled supported rates, e.g. "44.1 / 96 / 192 kHz" +} + +export global DacWizardState { + in-out property open: false; + // 0 welcome · 1 check · 2 select-dacs · 3 review · 4 test · 5 done + in-out property step: 0; + + // welcome + in-out property welcome-confirmed: false; + + // check (Slice 6): distro auto-detected, override always visible (Decision 4) + in-out property <[string]> distro-options: []; + in-out property distro-index: 0; + in-out property health-ok: false; + in-out property health-summary: ""; + + // select-dacs (Slice 7) + in-out property <[DacCandidateRow]> candidates: []; + in-out property has-enumeration: true; // false → manual escape hatch + in-out property manual-node-name: ""; + in-out property manual-valid: false; + + // test (Slice 9): live read-back lines, set by the controller + in-out property test-requested-label: ""; // "QBZ requesting 96000 Hz · 24-bit" + in-out property test-negotiated-label: ""; // "DAC running at 96000 Hz" (N6) + in-out property test-rate-matched: false; + in-out property test-playing: false; +} + +export global DacWizardActions { + // Wired in Rust from Slice 6 onward (crate::dac_wizard); navigation + // (open/close/next/back) is handled inline in the modal via state mutation. + callback copy-command(string); // → crate::share::copy_to_clipboard + callback run-detect(); // enumerate sinks → candidates (Slice 7) + callback query-caps(string); // probe one DAC's rates (Slice 7) + callback start-test(); // queue + play the 4 test tracks (Slice 9) + callback stop-test(); +}