Commit Graph
2448 Commits
Author SHA1 Message Date
vicrodh 924af24e91 fix(reco): route Recommendations MusicBrainz lookups through the core client
recommendations_qt and external_reco_qt built private MusicBrainzClient
instances, which start enabled and ignore the musicbrainz_enabled
opt-out that integrations_qt applies to the core's client. Expose the
core's Arc<MusicBrainzClient> and borrow it instead, so a disabled
MusicBrainz sends zero requests from Discover > Recommendations. The tag
editor's three private clients stay: those are explicit manual lookups
the user triggers by hand.

cc
2026-08-28 19:54:47 -06:00
vicrodh c7a3012955 feat(local-library): complete catalog and metadata workflows
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.

cx
2026-08-28 18:50:05 -06:00
vicrodh 0b7fa6da93 test(audio): honor platform support in direct-route assertions
cx
2026-08-28 09:44:25 -06:00
vicrodh fbc8e9fa31 docs(contributors): credit luukvanderduim for filter indicators
cx
2026-08-28 07:57:31 -06:00
vicrodh acc0e33028 docs(contributors): credit fengalin for crypto test coverage
Preserve attribution for PR #678 after its solution was superseded by the current crate-local implementation. Make the About grouping test grow with the contributor list.

cx
2026-08-28 07:43:55 -06:00
vicrodh e993bc1e4d merge: integrate PR #724 hotkeys and Vim keymap
Resolve translation conflicts against pre-release and harden miniplayer dispatch, direct-output volume locks, preset collision state, and Spanish labels during review.

cx
2026-08-28 07:02:33 -06:00
vicrodh 912638c387 merge: integrate Windows port from PR #725
cx
2026-08-28 04:18:06 -06:00
vicrodh 2df2dafe67 fix(audio): make ALSA transitions capability-aware
cx
2026-08-28 02:58:04 -06:00
vicrodh 0f69895f15 fix(qt): stabilize Japanese font fallback
cx
2026-08-28 02:57:48 -06:00
vicrodh 3da21c2752 fix(local): refresh availability after source changes
Reject stale availability probes across first-folder and last-folder mutations.

Refs #723

cx
2026-08-28 02:57:22 -06:00
vicrodh 3d0ee66708 fix(playback): harden idle queue and gapless transitions
cx
2026-08-28 02:57:09 -06:00
vicrodh 97f9b85d7f fix(linux): restore notification lifecycle
cx
2026-08-28 02:56:57 -06:00
Niklas Herder 56be744637 fix(qt): Escape must free the header search field
focusSearch() force-focuses the search input and nothing in HeaderBar ever
released it: the Escape arm only fired while the cortinilla was open, and
even then it dismissed the dropdown and left focus in the field. Since the
central dispatcher refuses every binding while a text input holds focus
(AppShell.qml:133-139), the field was a keyboard dead end whose only way out
was a mouse click.

Escape now always dismisses the dropdown if open, accepts, and hands focus
to the shell root via the same isQbzShellRoot duck-walk the modals use.
Clearing focus alone would not do: a null activeFocusItem passes the gate
but leaves AppShell's Keys handler receiving nothing at all.

Reachable from Ctrl+f since the port; the Vim keymap's `/` puts it one
keystroke from the reflex that expects Escape to work, which is how it
surfaced.
2026-08-28 07:57:48 +02:00
Niklas Herder b781ed283f feat(hotkeys): selectable Vim keymap preset
The preset swaps the BASE shortcut table underneath the user's overrides
instead of rewriting them, so Default <-> Vim is lossless. Vim binds single
modifier-free keys: n/p, h/l, Shift+J/K, /, and Ctrl+o / Ctrl+i for the jump
list; rows with no vim idiom keep their defaults.

j and k are deliberately left UNBOUND. They belong to a per-list cursor,
which QBZ has no notion of yet, and a global action bound there now would
have to be taken back the day one lands. A test holds them free.

Where an override has already claimed a shortcut the new base wants, that
action goes unbound rather than double-binding a combo that
action_for_shortcut would otherwise resolve by BTreeMap id order.

Seven new tests, including a gate that fails if any preset ever binds one
shortcut to two actions.
2026-08-28 07:27:43 +02:00
Niklas Herder 04f9afe83f feat(hotkeys): Spotify-parity playback actions
Eight Playback actions the table never had, on Spotify's desktop defaults:
volume up/down (Ctrl+Up/Down), mute (M), shuffle (Ctrl+S), repeat (Ctrl+R),
like (Alt+Shift+B), and a surface-independent seek (Ctrl+Shift+Left/Right).

Each reuses an existing transport seam, and the immersive bare-arrow seek
rows are untouched. Volume honours the player bars' volLocked predicate, so
the hotkey cannot move the slider on an ALSA-Direct hw path where the engine
documents the write as a no-op. Like rides a QML seam, as nav.search does,
because the track id is a QbzPlayer property rather than part of the Rust
now-playing model.

Two new tests; labels added to all eight catalogs.
2026-08-28 07:27:03 +02:00
vicrodh 7316f42896 test(cast): pin repeat at renderer queue edge
cx
2026-08-27 20:07:57 -06:00
vicrodh eb643b9d37 fix(qbzd): keep gapless fetches off driver loop
cx
2026-08-27 20:03:50 -06:00
vicrodh 08ccdc54c3 fix(linux): withdraw now-playing portal notifications
cx
2026-08-27 19:47:08 -06:00
vicrodh a21d33fe3b i18n: the Windows disclaimer's chrome, in the seven translated locales
Three strings: the modal's title, its "Don't show this again" box and its
button. The BODY is not among them and stays English on purpose -- it is the
author's statement of what the Windows build does and does not promise, and the
wording is the point.

`en` is deliberately untouched. That catalogue is sparse, carrying only the
strings whose English differs from the msgid, and a missing lookup falls back
to the msgid, which is already the English text.

The register follows what each file already had, "I understand and accept these
terms" in particular, so the button reads like the rest of the app rather than
like a second voice arriving.

cc
2026-08-27 19:38:24 -06:00
vicrodh 5bae442864 fix(qbzd): enforce quality cap for QConnect
cx
2026-08-27 19:37:00 -06:00
vicrodh c068b2fd65 feat(audio): WASAPI exclusive-mode rate probe and hotplug watch
The Windows counterpart of alsa_backend::get_device_supported_rates. It exists
because the answer cannot be inferred: measured on the box, the owner's DAC
accepts 44100/48000/88200/96000/192000 in exclusive mode and REFUSES 176400 --
the 44.1 family stops at 88.2 while the 48 family reaches 192 -- while a second
endpoint on the same machine does accept 176400. No rule about higher rates
implying lower ones survives that, and 176400 is the DSD64 DoP carrier.

Exclusive is the only question worth asking. IsFormatSupported(SHARED) answers
about the mixer, which resamples and therefore says yes to nearly everything.

"I could not ask" is not "it supports nothing". A busy endpoint, a failed
activation and a device that genuinely accepts nothing all produce an empty
list, and the first draft cached that as authoritative -- which would have
outlived its cause, because a stream CLOSING raises no device notification.
Only a real answer is stored.

The DoP guard is now FAIL-CLOSED on Windows, and the fix turned out simpler
than probing: the DoP handler is cfg(target_os = "linux") and answers "DoP
playback is Linux-only" with a stream error, so dispatching there can only
fail. Converting to PCM is the only outcome that plays the track. It matters
that this is closed rather than open: an undecoded DoP stream is read as PCM
carrying packed DSD and marker bytes, which comes out as very loud broadband
noise. An earlier draft fell back to "yes" whenever the probe could not answer
-- preserving exactly the guess this work existed to remove.

The hotplug callbacks run on a thread the Windows audio system owns, where the
documented rule is to return immediately. Each does ONE lock-free atomic
increment and nothing else: no mutex, and no logging either, since a log macro
can allocate, take its own lock and perform synchronous output. Invalidation is
a generation counter rather than per-endpoint removal, which cannot block,
cannot lose an event, and matches the real granularity -- a device arriving
changes exclusive contention for endpoints other than itself. The generation is
read BEFORE a probe and stored with the result, so an event landing mid-sweep
expires that answer instead of being overwritten by it.

One IAudioClient serves every probe: IsFormatSupported may be called any time
after activation and neither initialises nor reserves the endpoint, so a client
per probe was forty activations buying nothing. A sweep gate coalesces
concurrent misses -- startup enumerates from two independent places and the log
showed every endpoint measured exactly twice. Measured after: four sweeps for
four endpoints, 34 ms for the lot.

is_supported, deliberately not is_supported_exclusive_with_quirks: the sweep
must predict what the OPEN path does, and that path asks the plain question. A
rate accepted only through a quirk the opener never tries would appear in the
UI and then fail to open.

cc
2026-08-27 19:34:59 -06:00
vicrodh 251f153a6a fix(qbzd): harden event hook execution
cx
2026-08-27 19:34:29 -06:00
vicrodh e109e88ec2 merge: integrate qbzd event hooks from PR #700
cx
2026-08-27 19:26:13 -06:00
vicrodh b1e27457e8 feat(qt): expand immersive visualizer experience
cx
2026-08-27 19:16:27 -06:00
vicrodh 6e62724b4b fix(qt): preflight preferred GPU presentation
cx
2026-08-27 17:54:27 -06:00
vicrodh afa32d9bba feat(windows): single instance and protocol deep links
The Windows sibling of single_instance_qt.rs. Same three entry points and the
same present-or-defer behaviour, so main.rs reads identically on both.

A named mutex arbitrates and a named pipe carries the message, because those
are different problems. CreateMutexW answers "did it already exist" atomically,
with no window for a second launch to slip through. A pipe cannot be asked that
without FILE_FLAG_FIRST_PIPE_INSTANCE, and QLocalServer never passes it
(PIPE_UNLIMITED_INSTANCES), so two processes could both believe they were the
server.

The plan asked for QLocalServer here, which meant a new Q_OBJECT file in the
moc list and .qt_module("Network"). A raw named pipe is the same primitive
underneath with none of that build surface, for a protocol two verbs long.

The pipe name is QUALIFIED BY SESSION ID, and that is not cosmetic: the mutex
is `Local\`, which is per-session, while pipe names are machine-global. With
one fixed name and two users signed in, each takes its own mutex but only the
first creates the pipe -- the second primary then serves nothing and ITS later
launches would forward to the OTHER USER'S process. PIPE_REJECT_REMOTE_CLIENTS
because the namespace is reachable over SMB and nothing about a second launch
is remote.

What arrives on the pipe is VALIDATED before it is acted on. It is not argv:
any process running as this user can write a line, and the payload otherwise
went straight to link resolution. The read is bounded at 4 KB for the same
reason.

`qbz://` is rewritten to `qobuzapp://` rather than taught downstream. The
resolver knows qobuzapp and the Qobuz web shapes and nothing else -- a live
test of the forwarding path came back "unsupported or invalid music link" for
a qbz:// URL, and a test in this file already pinned that is_qobuz_link
excludes it. The two carry the same album/track/artist shape, so a prefix swap
is the whole translation, and the MSI's registration finally means something.

A parse alone is not a gate: Url::parse("qbz://") SUCCEEDS, a valid scheme with
an empty everything, so the check requires a non-empty authority -- which is
where every accepted shape carries its target.

The URL validation in select_link is Windows-gated. ShellExecute can split a
link across argv there; Linux and macOS have accepted whatever matched the
prefix since this shipped, and tightening that from a Windows port would change
behaviour on platforms that never asked.

present_or_defer re-reads UI_READY after publishing PENDING_PRESENT. bind_ui
can flip the flag and run its own swap in between, leaving the pending bit set
with nobody to consume it and a second launch looking ignored.

Verified on the box: a second launch carrying a deep link exits 0 in about four
milliseconds, the primary logs the handoff and resolves the rewritten URL.
571 tests pass.

cc
2026-08-27 17:42:53 -06:00
vicrodh 27022fbf46 fix(qt): offer notifications only where a toast can actually arrive
`system_notifications_supported` answered `cfg!(windows)` in an earlier draft
and then a flat `false`; both were wrong in opposite directions. It now asks
the machine.

Setting the process AppUserModelID is necessary and NOT sufficient. An
unpackaged desktop app receives toasts only once Windows can RESOLVE that id,
through either a Start-menu shortcut carrying System.AppUserModel.ID or the
matching AppUserModelId registry key. The MSI writes both. A portable unzip has
neither, and every toast it raises is dropped in silence -- no error, no log,
nothing to point at.

So the row is offered on the strength of the registry key, read once at
document-build time. An MSI install sees it and gets the setting; a portable or
dev build does not, and the row stays hidden rather than presenting a switch
that can never produce a notification. That is the same "renders, persists and
drives nothing" failure the capability flags exist to prevent, and the reason
the flag was held back until the MSI existed to satisfy it.

Linux and macOS keep their compile-time answer.

cc
2026-08-27 17:00:23 -06:00
vicrodh 2e0e292775 feat(windows): the as-is disclaimer, once per version
A startup modal carrying the release notes' "Windows -- yes, but read this
first" section verbatim: Windows is not a supported QBZ platform, the build is
as-is, and the port is open for adoption.

Persisted as two fields, hidden plus the acknowledged version, written in ONE
transaction and read from ONE parsed document. Two separate whole-document
read-modify-writes could leave a torn pair after a crash or a failed rename,
and either half alone re-opens the modal -- the user would tick the box and see
it again. ui_prefs.json is shared with the shipping Slint app, so the discipline
lives in settings_qt with the rest of that file's writers.

A version rather than a bool alone is what makes "reset it on every update"
need no reset step: a build whose version differs simply does not match. A
reinstall of the SAME version does not bring it back, which is accepted -- the
prefs file outlives the installer, and the MSI is where that would be cleared.

Focus is taken in Component.onCompleted, not onVisibleChanged. Item.visible
defaults to true and the Loader only creates this when it should already be
shown, so there is no false->true transition to observe: the handler would
never have run, Escape would never have reached it, and ordinary hotkeys would
have kept working behind the modal. Focus is handed back to the shell root on
destruction, because Qt strands activeFocus on an item being destroyed and that
kills AppShell's key dispatcher until the next click -- the failure
QbzConfirmModal already documents.

Mounted through a Loader gated on isWindows. `visible: false` still constructs
the whole subtree, and Linux and macOS have no reason to build a panel, a
Flickable and fourteen delegates for a modal they can never show.

The body is NOT translated, deliberately: it is the author's statement of
policy and the wording is the point. The chrome around it goes through tr.

cc
2026-08-27 16:52:17 -06:00
vicrodh 415cba57de feat(qt): custom title bar on Windows, with a native hit test
Three requirements -- no title bar, working clicks, and window tiling -- and no
combination of public Qt flags delivers all three. Each attempt was measured on
the box rather than reasoned about, and each one taught something the plan had
wrong.

FramelessWindowHint, the previous arm, removes WS_THICKFRAME. THAT is what the
shell keys tiling on -- not WS_CAPTION, which this window never has in any
arrangement -- so Win+Arrow and Aero Snap were dead, along with the DWM shadow
and the Win11 rounded corners. "Hide title bar" was not making the app refuse
the shortcuts; it had stopped being a window the shell could tile.

Adding CustomizeWindowHint restored tiling and stopped Qt painting its own
minimise/maximise/close cluster over the QBZ header. It also made Qt answer
WM_NCHITTEST with HTNOWHERE for EVERY pixel -- centre, header, player bar and
sidebar all returned 0 -- and Windows delivers no mouse input to HTNOWHERE, so
the whole app became unclickable while still running: the tray menu still drove
playback. Dropping the hint fixed clicks and brought the drawn buttons back,
duplicated on top of the ones QBZ already draws itself.

So the hint stays and cxx/win_shell.cpp answers the hit test instead. The
filter defers to DefWindowProcW, which is what an ordinary sizable caption-less
window would return: the eight edge codes near the frame, HTCLIENT everywhere
else. It invents no geometry. Its guard is narrow on purpose -- sizable AND
caption-less, which is exactly the custom-chrome main window -- so menus,
tooltips, popups and any window showing the system title bar keep Qt's own
answer.

Measured after: HTCLIENT at the centre, in the title band, at the edges and in
the corner; Win+Left resized the window to 1720 px, exactly half of a 3440 px
display.

Dragging is unaffected and stays with QML's startSystemMove(): with no caption
there is no HTCAPTION band to inherit, which is the arrangement HeaderBar was
already written for.

NOT restored, and not restorable from flags: the Win11 Snap Layouts hover
flyout. Windows asks for it by hit-testing the maximise button as HTMAXBUTTON,
and QBZ's is a QML item inside the client area. Reporting HTMAXBUTTON over that
one rect from this filter would do it -- named as future work rather than
claimed.

cc
2026-08-27 16:52:17 -06:00
vicrodh 3b032e8c4a feat(windows): persist the session when Windows ends it
Windows asks WM_QUERYENDSESSION before it logs off or shuts down; Qt turns that
into commitDataRequest and BLOCKS inside the signal until the handler returns,
which is the one moment a synchronous persist is safe. Anything deferred to a
queued connection or another thread races the process being killed.

BOUNDED at 3 s, and that is the design rather than a precaution. Windows starts
terminating applications that have not answered after roughly five seconds, so
an unbounded save does not protect the session -- it gets the process killed
halfway through writing it. The save runs on its own thread and the GUI thread
waits on a deadline under the OS limit; if it passes we return and let the
session end, and the log says which happened. A tokio timeout around the future
would not do, since it cannot preempt the synchronous SQLite section.

Timeout and Disconnected are reported separately: a panic in the worker drops
the sender and would otherwise be logged as a slow disk. The outer catch_unwind
guards the C ABI boundary on this thread and cannot see that one.

WINDOWS ONLY, though the hook is portable. Qt's session manager exists on X11
too, so installing it everywhere would add a blocking save to the Linux logout
path, which can only ever delay a logout. W25 is a Windows contract item; Linux
can adopt it deliberately rather than inherit it from a port.

The C++ side gained a once-guard -- Qt::UniqueConnection does not deduplicate
lambdas, so a second install would persist twice -- and a QT_NO_SESSIONMANAGER
guard, since this file compiles on every platform and commitDataRequest does
not exist in a Qt built without it.

cc
2026-08-27 16:52:17 -06:00
vicrodh c2272ad9f3 feat(windows): miniplayer keep-on-top, Windows only
A deliberate per-platform divergence, not a revert of ruling K1. That ruling
removed always-on-top because the owner smoked it on real Plasma Wayland and
the hint did nothing: a Wayland client cannot raise itself above other clients,
there is no such request in the core protocol, and Qt had nothing to send. On
Windows the same hint IS HWND_TOPMOST and does exactly what it says, so the pin
exists where the window manager can honour it and stays absent where it cannot.

Owner decision D8. `Qt::WindowStaysOnTopHint` is applied by SetWindowPos on the
existing HWND, so toggling it does not recreate the native window and costs
neither the geometry nor the transparent surface. The capsule already reserved
the slot, and Qt Rows omit invisible children, so the Linux and macOS layouts
are byte-identical.

Session-scoped by design: the pin does not survive a restart.

cc
2026-08-27 16:52:17 -06:00
Victor RH 2772b7486f Merge pull request #722 from vicrodh/feature/internal/qt-ci-workflows
ci: make test-crates green on the Qt tree (wayland dev lib, SafeArea guard, shader gate toolchain)
2026-08-27 14:21:21 -06:00
vicrodh d965f6a1d9 fix(windows): create the tray when the Qt thread is actually registered
The icon never appeared, and the log said NOTHING -- not even
"[tray] disabled by user setting", which is what `init_gates` prints when it
refuses. That absence is what identified it: the gates had passed and the work
itself had vanished.

`tray_bridge::ui` silently discards its closure until `QbzTray::boot` registers
the Qt thread, and `tray_qt::init` runs from `on_session_entered`, which
reaches it first. Every log line in that arm lives inside the closure, so
nothing was left to see.

`init` now records the intent and still tries the hop; `boot` calls the same
idempotent `create_now()` right after registering the thread. Whichever arrives
SECOND does the work. No polling and nothing to time out.

Verified on the box by differential capture rather than by the log: with QBZ
running the notification-area overflow holds eight icons, without it seven, and
the one that disappears is QBZ's. `HKCU\Control Panel\NotifyIconSettings` has
the entry with `InitialTooltip: QBZ`. It sits in the overflow because
`IsPromoted` is unset, which is what Windows 11 does with every newly seen app.

The macOS arm has the same shape and is NOT touched here: if `init` wins that
race there, the NSStatusItem is never created, nothing logs, and `tray_live`
keeps its derived false so close-to-tray stops working too. Recorded for the
owner rather than changed from a Windows branch, since the structural fix
(buffering early work in `tray_bridge::ui`) would also move Linux, where early
calls are dropped by design.

cc
2026-08-27 14:20:03 -06:00
vicrodh 1a2a9994dd feat(windows): system tray via Shell_NotifyIconW
The third sibling of the Linux ksni item and the macOS NSStatusItem, wired into
the same `tray_qt` seams and dispatchers, so a tray action behaves identically
on all three. Close-to-tray needs no QML change: `Main.qml:786` already keys on
`trayLive`, which both arms of `init` publish.

The menu is the SAME menu. The plan's C ABI carried no labels, which would have
shipped an English-only menu in an app with eight locales; Linux and macOS
already share five `qbz_i18n` keys, so the translated strings are passed in
from Rust and no new string is introduced.

Win32 details that are load-bearing rather than incidental:

- NIF_SHOWTIP. Version 4 SUPPRESSES the standard tooltip unless asked, so
  without it the icon appears, every track sets the tooltip, and nothing ever
  shows on hover.
- The NIM_SETVERSION result is checked and creation fails if it is refused.
  The window procedure decodes callbacks as version 4 and nothing else, so a
  silent negotiation failure would leave a live icon whose clicks and menu do
  nothing -- worse than no icon, because close-to-tray would then hide the
  window behind it.
- WS_EX_TOOLWINDOW, not HWND_MESSAGE. A message-only window never receives the
  broadcast TaskbarCreated, so the icon would never come back after an Explorer
  restart. The window is on the GUI thread, so Qt's own loop dispatches it.
- LoadImageW without LR_SHARED returns an icon this process OWNS; it is
  destroyed on teardown, while the LoadIconW system fallback is not.
- Failed creation gives back the window and the icon instead of parking them
  for the process lifetime, and create is idempotent.
- NIM_SETFOCUS after the menu closes, which the Shell asks for and matters most
  when it was dismissed with Escape.

Every mutation is marshalled through `tray_bridge::ui`, exactly as the macOS
theme arm is. The publishers run on whatever thread playback pushed from -- the
1 Hz poll lives in `crate::spawn` -- while this tray keeps process-global C++
state and talks to a GUI-thread window; calling straight through was a data
race, not merely a documentation mismatch. Linux needs no hop because ksni's
handle is a thread-safe channel.

Strings are truncated on the Rust side by UTF-16 CODE UNITS, not by `chars()`.
The buffers are sized in `wchar_t`, an astral character costs two, and
`wcsncpy_s` with `_TRUNCATE` would happily keep a high surrogate whose partner
was cut -- malformed UTF-16 handed to the shell.

The tray-icon-variant row is hidden on Windows: the notification area draws the
executable's own icon and Windows owns light/dark, so the four choices would
render, persist and change nothing.

cc
2026-08-27 13:55:46 -06:00
vicrodh c0664030ee feat(windows): keep the system awake while a track plays
Linux holds a logind inhibitor fd; Windows has SetThreadExecutionState, and
nothing was calling it.

The plan asked for the call to sit in `set_playback`. It cannot: that API is
PER-THREAD -- Windows drops a thread's requests when it exits -- and this
module's own contract says outbound calls are "free from any thread"
(media_controls_qt.rs:34). In practice they arrive on the mpris-server thread,
a tokio worker, or the GUI thread, whichever pushed. A request made from a pool
worker would evaporate with it, silently. So the state is owned by one thread
that lives as long as the process and receives a bool.

ES_SYSTEM_REQUIRED without ES_DISPLAY_REQUIRED: an audio player keeps the
MACHINE awake, not the screen. No ES_AWAYMODE_REQUIRED either -- that is for
media-centre appliances and it suppresses the sleep the user asked for.

Exposed as a free function rather than a `PlatformHandle` method, and called
BEFORE the `handle()` early return on both publishers. Hanging it off the
handle tied it to SMTC: a failed souvlaki init, or an HWND that never arrived,
would have left playback working and the machine falling asleep mid-track --
two unrelated features failing together for no reason.

`held` is updated only after the API succeeds, so a failed release cannot leave
Windows inhibited while later `false` messages are skipped as no-ops.

cc
2026-08-27 13:55:45 -06:00
vicrodh eff9c484e6 fix(audio): align ALSA Direct visualizers
cx
2026-08-27 13:53:12 -06:00
vicrodh 9b0d8d7e0f feat(windows): AppUserModelID at startup and the toast arm
Two halves of the same identity.

`SetCurrentProcessExplicitAppUserModelID` runs before `QGuiApplication::new()`,
which is the documented window: Windows caches the identity with the first
window and a later call fails. Without it the taskbar treats each launch as an
anonymous window and does not group it with the Start-menu shortcut. The
HRESULT is logged on failure -- when this breaks, taskbar grouping and toast
delivery both break silently, and the only symptom would otherwise be
"notifications do not work" with nothing to point at.

The notification arm follows the macOS one, with `app_id` matching the AUMID.
notify-rust's Windows backend forwards summary/body/app_id/image and SILENTLY
DROPS `actions`, so no buttons are attempted: they would look implemented and
do nothing.

The artwork cache is shared rather than duplicated -- its four `cfg` gates now
include Windows, which also retires an unused-import warning there. Its
`file://` resolver gained the two Windows shapes: the empty authority's slash
in front of a drive letter (`/C:/x`, which PathBuf keeps and then never
resolves), and a UNC authority (`file://nas/share/x`), which was being treated
as a path relative to the working directory.

`system_notifications_supported` deliberately stays false on Windows. An
unpackaged desktop app only receives toasts once Windows can RESOLVE its
AppUserModelID, which needs a Start-menu shortcut carrying
System.AppUserModel.ID -- written by the MSI, which does not exist yet. Setting
the process identity is necessary and not sufficient. Advertising the row now
would let the setting be enabled in exactly the builds where nothing can ever
appear. It flips with the MSI.

cc
2026-08-27 13:37:25 -06:00
vicrodh 114824b7bc feat(media-controls): SMTC with a real HWND, and a native cover path
Windows media keys never worked because SMTC never started. souvlaki's Windows
backend does `config.hwnd.expect(..)`, which PANICS rather than degrading, and
`MediaControls::new` runs inside a tokio task, so a missing handle aborted the
task instead of returning the Err the caller was written for. Phase A bowed out
before reaching it and logged "SMTC disabled: no window handle wired yet".

`spawn` now takes a `NativeWindow`, and `cxx/win_shell.cpp` supplies the
top-level HWND through `QGuiApplication::topLevelWindows()`. The guard stays:
a missing integration is a missing feature, a panic in a tokio task is a dead
session.

Windows reads the handle from a bounded retry on the GUI thread. Both things it
waits for -- the tray bridge's Qt thread and a shown window -- are "not yet",
never "not ever": `tray_bridge::ui` is a no-op until `QbzTray::boot` registers
the thread, and `QbzSession.boot()` runs first (Main.qml:254 vs :380).

NON-WINDOWS IS UNCHANGED, deliberately. An earlier draft routed every platform
through that hop and it was a Linux/macOS regression three ways: initialization
could be dropped outright before boot, MPRIS would start only when the Qt queue
drained (losing the first metadata push), and `main_window_hwnd` calls
`QWindow::winId()`, which CREATES the platform window as a side effect on
platforms with no reason to.

Cover art needed its own fix. souvlaki 0.8.3 branches on
`url.starts_with("file://")`, then trims exactly that prefix and hands the rest
to `GetFileFromPathAsync`. So `file:///C:/x.jpg` arrives as `/C:/x.jpg` -- a
slash before a drive letter, which never opens -- while a bare `C:\x.jpg` no
longer matches the prefix and takes the remote-URI branch instead. The value
that works is the hybrid `file://C:\x.jpg`. UNC is handled too: `file://nas/m/a`
is a real Windows path and was being passed through as the relative `nas/m/a`.

The percent-decode is full and applied once: `art_url` arrives from
`url::Url::from_file_path` (space as %20) and from `fs_url::file_url` (only
% # ? escaped), and one full decode reads both -- `%2520` gives back a literal
`%20` rather than a space. `fs_url::local_path` is deliberately not used here;
its three-escape decode would leave the other producer's %20 intact.

Eight tests cover the matrix. Reviewed adversarially before commit, which is
where the inverted cover conversion and the non-Windows regression were caught;
the souvlaki branch was then read at the source to confirm.

cc
2026-08-27 13:37:24 -06:00
vicrodh ff6f40f9dd fix(player): let a direct sink answer for its own hardware volume
When the Direct arm moved from a concrete AlsaDirectStream to
Arc<dyn DirectSink>, the set_hardware_volume call kept its
cfg(target_os = "linux") gate, so on every other platform the setting
silently did nothing.

Added as a second arm rather than by removing the Linux one, per the
protected-code rule. Linux keeps its verbatim block and its behaviour;
a Windows sink can now answer for itself, and one with no hardware volume
returns Err and says so instead of the call never happening. macOS and the
remaining targets keep their previous silence, since CoreAudio has its own
path through set_coreaudio_hardware_volume.

Latent rather than live: the row is visible only on ALSA.

cc
2026-08-27 12:44:21 -06:00
vicrodh 0c7629b275 fix(credentials): derive the Windows machine id from COMPUTERNAME
machine_id_stable_source had no Windows arm. It read /etc/machine-id, then
HOSTNAME, then USER -- two UNIX names Windows itself never sets but an
MSYS environment may.

Measured on the box: Git-Bash sets HOSTNAME as a shell variable it does not
export, so a child process reads None, and leaves USER empty. The chain
returned None and the persisted random fallback kept the key stable. Stable by
accident: any environment that exports either name would silently derive a
different key, and a key that moves is a credentials file that no longer
decrypts.

COMPUTERNAME is set by Windows for every process, however the app was
launched, so the value is now stable by construction. Approved with the
one-time re-authentication that moving the key costs; verified by smoke that
no re-authentication was in fact needed, because the master key lives in the
Credential Manager and this derivation is the fallback path.

cc
2026-08-27 12:44:21 -06:00
vicrodh cb2a61cbd9 fix(secrets): stop the machine-id probe flashing a console
install_id spawns reg query for MachineGuid. The shipped image is a
GUI-subsystem binary and owns no console, so Windows allocated one and a black
box flashed at every startup. CREATE_NO_WINDOW suppresses it, through
std::os::windows::process::CommandExt, with no new dependency.

The command, its arguments, its stdout and its parse are untouched. That
matters: this value is a KDF input, and .output() captures the same bytes --
only the console attachment changes.

cc
2026-08-27 12:44:20 -06:00
vicrodh 249e30766e fix(audio): skip the Linux stack probes on Windows
A spawn of an image that does not exist fails before a process is created, so
pactl, pw-dump and systemctl were harmless on Windows. sh is not: it exists
wherever Git for Windows is installed, so the five sh -c probes in
audio_stack_health really ran and flashed five console windows past a
GUI-subsystem binary that owns no console.

Scoped to windows / not(windows), NOT to linux / not(linux). macOS and the
BSDs can have a real pactl or pw-dump from Homebrew or ports, and answering
false for them would take away an answer they can give.

qbz-theme needed nothing: its dispatch is by XDG_CURRENT_DESKTOP, so Windows
resolves DesktopEnvironment::Unknown, whose arm returns Err without calling
any desktop probe.

cc
2026-08-27 12:44:20 -06:00
vicrodh 1503b8236f feat(core): read the Windows memory figures from GlobalMemoryStatusEx
detect_profile fell back to MemoryProfile::from_total_kb(u64::MAX) -- the top
tier -- on any machine without /proc/meminfo, and read_memory_pressure
returned None forever. The prefetch and buffer tiers were therefore pinned at
the top on Windows and the memory watchdog was blind: it continues on None, so
evict_l1_audio_cache and the prefetch halt could never fire at any pressure.

Both halves had to land together. With the old u64::MAX total, a working
pressure probe would have computed roughly 0 per cent available and reported
critical permanently.

ullAvailPhys is the analogue of MemAvailable: what an allocation can take
without going to disk. The Normal-fallback contract is preserved, so a probe
that fails still never throttles the machine. windows-sys 0.61 is already in
the workspace lock via qbz-local-catalog and qbzd, so nothing new is pulled.

Measured on a 32 GB box: 57.9 per cent available, class Normal, which is the
same class the old fallback produced -- the change is the true total and a
pressure figure that exists at all.

cc
2026-08-27 12:44:20 -06:00
vicrodh 5ba49a7390 fix(qt): hide the Windows rows nothing can honour
Three settings and menu entries offered Windows something no code behind them
could do.

DSD playback was opened to WASAPI in fc0d35e29, written before the phase B
spike. The spike then measured this DAC refusing 176400, the DSD64 DoP
carrier, so DoP over exclusive mode lost its target; and both non-convert
modes are cfg(target_os = "linux") in the audio thread and answer "DoP
playback is Linux-only" with a stream error. The row could only offer two ways
to fail. Back to ALSA-only. DSD still plays on Windows, converted to PCM.

Exclusive mode became visible the moment phase B published backendIsWasapi,
but the selection arm keys on backend_type == WasapiExclusive and never reads
exclusive_mode. A live toggle that changes nothing, directly under the
BIT-PERFECT header and directly above the thing being measured.

Open audio CD is dropped on Windows: the CDDA backend is mod unsupported there
and answers NoDrive to everything. Open SACD image stays on every platform --
it opens a file, not a drive, and hiding it would have been a bug.

cc
2026-08-27 12:44:20 -06:00
vicrodh b52c17ee24 fix(audio): keep the WASAPI render thread alive across a stop
WASAPI Exclusive played nothing at all. The stream opened correctly every
time (Initialized EXCLUSIVE @ 44100 Hz, S24In32, DirectHardware); the render
thread was simply gone by the time audio arrived:

  Stop requested
  Writer thread started (gapless-capable)
  stream closed with 2 underrun(s)
  ERROR Write failed: render thread is gone

The player calls stop() on EVERY track change and then reuses the same stream
object for the next track. ALSA states that contract in its own comment --
"Stop PCM immediately (prepare for next playback)": DROP to halt and discard,
then prepare, and the object survives. Ctl::Stop here did break, so the stream
was single-use and the first track never reached the device.

Ctl::Stop now halts (stop_stream + reset_stream), discards the carry and the
queued PCM, answers any pending drain, and parks waiting for the next track's
first chunk, which it pre-rolls before restarting. The IAudioClient is never
re-Initialized, so the exclusive lock is held across the track change.

Three consequences, each found before shipping:

- Ctl::Shutdown is new, and only Drop sends it. Drop's comment asserted "the
  thread's own loop exits on Stop"; once that stopped being true, join()
  blocked forever at process exit. Measured as a 10-minute hang.
- stop() now blocks on an acknowledgement. Fire-and-forget raced the player,
  which queues the next track's PCM immediately after stop() returns, so the
  purge could eat audio belonging to the track that was starting.
- The halt and pre-roll results are no longer discarded, a Drain arriving
  while stopped completes at once instead of blocking for the deadline,
  drain() reports Err when a stop threw the audio away rather than claiming
  it played, and Resume no longer Starts an unprimed Reset stream.

The example only tested drain-then-write, which is why a stream that could not
play a second track passed it. It now plays three audible one-second beeps --
after playback, after the drain, after the stop -- and judges underruns on
continuous playback only, since the gaps between beeps are underruns the test
creates itself.

Measured against the DAC at 44100 and 192000: three beeps, stop acknowledged
in 109 ms, write after stop OK, 0 underruns, 0 event timeouts, exit 0.
Verified by smoke in the app: one exclusive open at 96000 Hz across two track
changes, zero "render thread is gone", and gapless confirmed audibly.

cc
2026-08-27 12:44:20 -06:00
vicrodh 09fcdeaa06 feat(audio): WASAPI Exclusive as a selectable backend
AudioBackendType::WasapiExclusive, offered on Windows, plus the selection arm
that opens the exclusive stream before the shared one - the same shape ALSA
Direct has on Linux.

No separate enumeration was needed: 249adc77b already made AudioDevice.id the
WASAPI ENDPOINT ID, which is exactly what WasapiDirectStream::new wants. That
fix turns out to have been the load-bearing one; by friendly name this could
not have addressed a device at all.

Two deliberate fallbacks, both of which prefer a resampled track to silence:

  - No device chosen: there is nothing to open exclusively, so it says so and
    uses the shared stream. Grabbing the default DAC would be deciding for the
    user.
  - The endpoint refuses the rate: warn and fall through. A DAC that will not
    take 192/24 in exclusive still plays it shared.

The mode LED lights EXCL only when a device IS chosen, because with none the
player falls through to shared - a lit EXCL there would be the LED lying,
which is the one thing output_labels must never do.

backendIsWasapi joins the settings document, which is what unhides the
Exclusive-mode and DSD rows that fc0d35e29 gated on it.

229 + 71 + 110 tests pass; five QML audits pass.

cc
2026-08-27 11:19:19 -06:00
vicrodh 2aa8d2d5f1 refactor(player): one Direct engine variant for ALSA and WASAPI
PlaybackEngine::AlsaDirect and StreamType::AlsaDirect become Direct, carrying
Arc<dyn DirectSink> instead of Arc<AlsaDirectStream>. Eleven match arms and
seven sites keep working untouched because they only ever called trait
methods; duplicating them per platform would have been the alternative.

CORRECTION to e61cd7bd8's message: it claimed the ALSA path still
monomorphises to static dispatch. That was true when written and is NOT true
now - the payload is a trait object, so those calls are virtual. Measured
where it lands: write_f32 runs once per 8192-frame chunk, i.e. 5.4 indirect
calls/second at 44.1 kHz and 23.4 at 192 kHz, each immediately entering
blocking device I/O. channels() once at construction, sample_rate/stop/drain
at boundaries. Nothing in the sample path.

Two things Windows CANNOT compile-check, found by reading rather than by the
compiler, and both would have broken the Linux build:

  - Four cfg(linux) arms passed a matched Arc<dyn DirectSink> to
    new_alsa_direct, which takes the concrete type. Arc unsizes one way only.
  - set_hardware_volume is called inside a cfg(linux) block and was not on the
    trait. It is now, with a default that REFUSES rather than silently
    succeeding, overridden by ALSA on Linux only - a sink with no mixer must
    not pretend it moved the volume.

StreamType::Direct is also no longer cfg(linux). Gating it was what stopped
this compiler from checking any arm that handles it, which is the exact blind
spot this port has to work around; the variant now compiles everywhere and
Windows will refuse anything I get wrong in it.

The log prefix moved onto the sink (DirectSink::log_label) rather than being
passed in: four call sites hard-coding "ALSA Direct Engine" is four chances to
mislabel a WASAPI stream. play/pause/stop/set_volume use it too - previously
only the writer thread did, so a WASAPI stream would have logged ALSA
lifecycle lines.

Sol reviewed as the Linux compiler before this commit and found no Linux type
error; the stale references and hard-coded labels above are its findings.

cc
2026-08-27 11:11:49 -06:00
vicrodh eada3bd12e fix(qt): keep the Qt 6.9 SafeArea/padding API macOS-only
Window.topPadding..rightPadding and the SafeArea attached type are Qt
6.9; on the Linux pin (6.8 LTS) declaring them on the ApplicationWindow
refuses the whole component at Main.qml:173 ("Cannot assign to
non-existent property"), measured on the CI VM 2026-08-25 and on a Pi 4
with Debian 13's Qt 6.8.2. The paddings are now set imperatively from a
Loader active only on macOS, and HeaderBar reads SafeArea.margins.left
through a macOS-only Loader into a plain property, so no binding names
the type on other platforms. Behaviour on macOS is unchanged.

cc
2026-08-27 11:11:47 -06:00
vicrodh e61cd7bd8a refactor(player): one writer thread for every bit-perfect direct sink
ALSA and WASAPI need byte-identical behaviour in the writer thread - same
chunking, same gapless hand-off, same drain - and two copies of 112 lines
drift. `qbz_audio::backend::DirectSink` names the five methods the engine
already called on AlsaDirectStream, both streams forward to their inherent
methods with no wrapping, and `alsa_writer_thread` becomes
`direct_writer_thread<S: DirectSink>` plus a log label.

Linux is unchanged: the ALSA call site passes its old label verbatim, so
every emitted string is identical, and `S` infers to AlsaDirectStream, so the
call monomorphises to the same static dispatch it had - no trait object, no
vtable in the audio path.

Sol reviewed this before the commit, per the rule added today, and found the
defect that mattered:

  WasapiDirectStream::drain only SENT Ctl::Drain and returned immediately,
  while the trait - and the ALSA drain it now shares a thread with - promises
  to play the queue out first. Worse, the render thread EXITED after draining,
  so a later append would have written into a dead thread. Gapless on Windows
  would have been broken in a way no unit test reaches.

drain now blocks on a reply from the render thread, and the render thread
answers and KEEPS RUNNING. Proven with the example: drain returns Ok in 28 ms
rather than 0, and a write after it succeeds. Starvation is also counted as an
EVENT now rather than per period - an idle stream otherwise reported hundreds
of underruns for one real one.

Sol also caught two stale doc references to the old function name and a
"draining ALSA buffer" line that a WASAPI stream would have printed.

cc
2026-08-27 10:52:56 -06:00
vicrodh 4cad40753f docs(qt): correct how PowerShell leaves an empty backend env var
The parenthetical added in 1a01abde6 says `$null` removes the variable. It
does not, for the call the comment is about: PowerShell coerces `$null` to
String.Empty when binding a `string` parameter, so
`[Environment]::SetEnvironmentVariable('X', $null, 'Process')` leaves the name
PRESENT and empty, and a child process inherits it that way. That is the call
that produced the original observation.

`$env:X = $null` IS the one that removes it, so both spellings belong in the
comment rather than one standing for the other.

Measured on Windows 11, PowerShell 7.6.5, with a child that enumerates its own
environment rather than using cmd's `if defined` - which collapses "empty" and
"absent" and is what made an earlier reading of this look the other way:

  [Environment]::SetEnvironmentVariable('X', $null, 'Process')
  python -c "import os; print('X' in os.environ, repr(os.environ.get('X')))"
  -> True ''

  never set at all
  -> False

The POSIX half of the comment is unchanged and was already correct: `VAR= cmd`
delivers `Some("")`, verified the same way.

Comment only; no behaviour change.

cc
2026-08-27 10:37:23 -06:00