Commit Graph
100 Commits
Author SHA1 Message Date
vicrodh a9c886b0d3 fix(media-controls): stop re-asking login1 for an inhibitor every 2 s
`SleepInhibitor::acquire` runs on every Playing state update and retried
the `Inhibit` call each time it failed. On a headless daemon with no
logind session polkit answers `InteractiveAuthorizationRequired` forever,
which produced one WARN every ~2 s (369 lines in 11 min on the Pi smoke,
2026-08-29). Classify the failure: a policy denial is logged once at info
and never retried for this process; a transient error backs off 5 min.
Playback was never affected.

cc
2026-08-29 18:35:03 -06:00
vicrodh e90ea8cada fix(qbzd): warm the successor on every track start and stream cold gapless hand-offs
The daemon only prefetched the next track after a gapped advance, so every
edge after a seamless hand-off (and every QConnect/API play) reached the
10 s gapless window cold and had to materialize the whole next file in
time. Whether it made it was file size vs bandwidth (#699 follow-up on a
1 GB Pi: gapless "sometimes, varies by album"; alternating pattern).

Add a `WarmSuccessor` driver action fired once per current track id on
the first tick that sees it, for every entry point, run as a driver-owned
task that never blocks the control loop; drop the awaited prefetch from
`advance_and_play`. For a successor that is still not cached at arm time,
append its initial CMAF buffer as an incremental stream
(`queue_gapless_streaming`, the desktop's hand-off) and keep the
whole-file byte fetch only as fallback.

Tests: 2 new driver tests (warm once per track for any entry, seamless
edge warms the new successor); qbz-app 269 and qbzd 298 pass.

cc
2026-08-29 16:11:54 -06:00
vicrodh bb43dea5b9 merge: integrate playback recovery and playlist menu
cx
2026-08-29 14:56:13 -06:00
vicrodh 3dbc1fc9d0 fix(qt): restore playlist header menu
cx
2026-08-29 14:55:30 -06:00
vicrodh 9e1f2c5559 fix(playback): recover stalled streams
cx
2026-08-29 14:55:21 -06:00
vicrodh 7d9d3b896e merge: dependency bumps for 2.1.0 (rusqlite 0.40, reqwest 0.13, rand 0.10, prost 0.14, …)
Verified by smoke on the release build (login, playback, scrobble, Local
Library, file dialog, cast discovery).

cc
2026-08-29 12:48:34 -06:00
vicrodh 8964544891 chore(deps): reqwest 0.13 (rustls-no-provider; query/form are opt-in features now)
cc
2026-08-29 09:00:56 -06:00
vicrodh 3785a7eb40 chore(deps): rfd 0.17 (xdg-portal; the async-std feature is gone)
cc
2026-08-29 08:53:42 -06:00
vicrodh 6d8c951a38 chore(deps): rusqlite 0.40 (u64 casts; a latent two-statement execute fixed)
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.

cc
2026-08-29 08:36:21 -06:00
vicrodh d89397415b chore(deps): mdns-sd 0.21 (ScopedIp addresses)
cc
2026-08-29 08:26:25 -06:00
vicrodh 1beb061198 chore(deps): mpris-server 0.10
cc
2026-08-29 08:26:25 -06:00
vicrodh 51e496e47f chore(deps): tokio-tungstenite 0.30
cc
2026-08-29 08:26:25 -06:00
vicrodh 957ce924b3 chore(deps): prost 0.14
cc
2026-08-29 08:24:28 -06:00
vicrodh 0b4c9e28b5 chore(deps): base64 0.23
cc
2026-08-29 08:24:28 -06:00
vicrodh 06453f8768 chore(deps): roxmltree 0.21
cc
2026-08-29 08:24:28 -06:00
vicrodh 0cded1e395 chore(deps): rand 0.10 everywhere (qbz-player, qbz-secrets were on 0.9)
RngCore/fill_bytes -> RngExt/fill, the trait names rand 0.10 exports.

cc
2026-08-29 08:24:28 -06:00
vicrodh c4d3fc7e3b chore(deps): cargo update — 194 semver-compatible bumps
cc
2026-08-29 08:23:36 -06:00
vicrodh 51daf760b7 docs+ci: two glibc floors — x86_64 2.35, arm64 desktop 2.39, qbzd 2.35 everywhere
Decided 2026-08-29: the arm64 Qt build only works on ubuntu-24.04-arm
(aqt's arm64 host tools segfault on 22.04-arm), so the arm64 desktop
app requires glibc 2.39; qbzd keeps building on 22.04-arm at 2.35 so a
trimmed Pi OS bookworm image stays a valid target. The floor gate asserts
per arch instead of warning; README states both floors.

cc
2026-08-29 08:08:14 -06:00
vicrodh a1763df250 merge: release workflows green on every platform (desktop-entry gate, Icon Composer icon, arm64 runner)
cc
2026-08-29 07:54:17 -06:00
vicrodh d66c993ade ci(macos): accept @rpath Qt install names with a bundle-relative LC_RPATH
Qt 6.9's macdeployqt leaves @rpath/Qt*.framework references resolved
through LC_RPATH @executable_path/../Frameworks; the old assert only
accepted a literal @executable_path and failed a correct bundle. Reject
absolute paths, require the rpath, and let the offscreen smoke prove it.

cc
2026-08-28 21:41:15 -06:00
vicrodh e70e5e4ab9 ci(linux-arm64): experiment — build aarch64 on ubuntu-24.04-arm (aqt qmake segfaults on 22.04-arm)
Both 6.8.3 and 6.9.3 arm64 qmake die with SIGSEGV on ubuntu-22.04-arm
with no missing libraries. Try the 24.04-arm image; the glibc floor gate
warns instead of failing for aarch64 until the floor is decided.

cc
2026-08-28 21:13:51 -06:00
vicrodh 860f1ad67f ci(linux): the AppDir check no longer expects .DirIcon (appimagetool writes it)
cc
2026-08-28 21:10:49 -06:00
vicrodh fd980440af ci(linux-arm64): Qt 6.9.3 for aarch64 (aqt's 6.8.3 arm64 qmake segfaults on the runner)
The QMAKE handoff exposed the real failure: aqt's linux_gcc_arm64 6.8.3
qmake dies with SIGSEGV on ubuntu-22.04-arm, which cxx-qt-build reported
as QtMissing. The arm line pins 6.9.3 (QT_VERSION_LINUX_ARM64 overrides);
x86_64 keeps the 6.8 LTS floor. file/ldd diagnostics run before the
qmake query so the next such failure names itself.

cc
2026-08-28 21:10:04 -06:00
vicrodh 37a1a75de1 ci(linux): pass QMAKE to cxx-qt-build; fix(player): macOS match covers StreamType::Direct
aarch64 runner: cxx-qt-build reported QtMissing with a correct
QT_ROOT_DIR — set QMAKE/CMAKE_PREFIX_PATH explicitly (as macOS/Windows
already do) and print the qmake version before the build.

macOS: StreamType::output_sample_rate (macOS-only) matched Rodio only;
Direct is intentionally not cfg-gated, so the macOS build failed with
E0004. A direct sink answers with its opened rate.

cc
2026-08-28 21:03:47 -06:00
vicrodh 7b05ef55ae fix(listen-log): a gapless hand-off closes the previous row as natural, not skip
Under gapless the engine never reports a not-playing tick between two
tracks, so the poll loop's track_ended edge does not fire and the next
track's edge found the previous row still open and closed it as skip
(smoke: 229 s of a 230 s track recorded as skip). The Qt edge now uses
the tracker's inference (last observed position within 2 s of the
duration = natural), the same rule qbzd already applies.

cc
2026-08-28 20:50:26 -06:00
vicrodh 8f35809df7 ci(release): desktop-entry icon gate; Icon Composer app icon on macOS
Linux: scripts/packaging/desktop_entry_check.sh asserts the MPRIS
DesktopEntry constant against the recipes (nfpm basename + Icon=,
StartupWMClass, reverse-DNS basenames) and against the built deb/rpm/
AppDir. The AppDir's desktop file now carries the reverse-DNS basename
so an integrated AppImage resolves the media-widget icon too. This
invariant regressed more than once; it is a gate now, not a rename.

macOS: compile packaging/icons/QBZ.icon (Icon Composer, issue #712) with
Xcode 26's actool into Assets.car + CFBundleIconName=QBZ; icon.icns stays
as CFBundleIconFile for older macOS. The Intel leg moves to macos-26-intel
so both legs carry actool.

cc
2026-08-28 20:45:55 -06:00
vicrodh a3161254e1 merge: listen log, cross-source identity and §12.0 debts
cc
2026-08-28 20:45:38 -06:00
vicrodh ed10a49562 chore(source): set the new QueueTrack identity fields in the remaining constructors
Plex and the Jellyfin/Subsonic test fixtures: None (the Plex cached row
does not carry the mbid yet).

cc
2026-08-28 20:31:19 -06:00
vicrodh 2208ca7a9b feat(audio): loudness_cache gains a content_key column (table only)
An ISRC or fingerprint column plus a partial index, with
set_content_key / get_by_content_key helpers, so a local or Plex copy of
the same recording can reuse an analysis. The analyser and every audio
path are untouched; set/get keep their track_id contract.

cc
2026-08-28 20:31:05 -06:00
vicrodh 4e41e92347 feat(plex): request includeGuids and keep the recording MBID
Section listings ask for includeGuids=1 and the track parser reads the
<Guid id="mbid://…"/> child (only the Plex Music agent writes it; other
schemes are ignored). Stored in a new plex_cache_tracks.recording_mbid
column (additive; COALESCE on upsert) and read back by the PlexTrack
mappers. PlexCachedTrack and the qbz-source Plex constructor do not
carry it yet — the catalog projection does.

cc
2026-08-28 20:31:05 -06:00
vicrodh e7135a424a feat(media-servers): capture the recording MBID and ISRC from Jellyfin and Subsonic
Jellyfin requests Fields=ProviderIds on both listing passes and maps
ProviderIds.MusicBrainzTrack (the recording id) into JellyfinTrack;
Subsonic reads OpenSubsonic musicBrainzId and the first isrc. Both land
in two additive remote_cache_tracks columns (isrc, recording_mbid), kept
across upserts with COALESCE so a known id is never overwritten by a
server that stops sending it, and travel to the queue through
qbz-source's remote constructor.

cc
2026-08-28 20:31:05 -06:00
vicrodh 702180cdea feat(library): read ISRC and MusicBrainz ids from tags; project them to the catalog
The scanner now keeps the identity tags Picard writes (ISRC/TSRC and the
MusicBrainz recording, track, release, release-group and artist ids —
the same lofty ItemKeys the tag writer already knows) in six additive
local_tracks columns, migrated idempotently (also after the sample_rate
table rebuild) and indexed on isrc and the recording id. Rows fill on
the next scan. The catalog projection (legacy.rs) stops hardcoding
isrc / musicbrainz_recording_id to None for local, remote and Plex rows
(the upsert already bound both), treating an empty string as no id.
qbz-source stamps both keys on local queue tracks.

cc
2026-08-28 20:31:05 -06:00
vicrodh c17f932d6c test: require the listen_log suite in the gate
cargo-test.sh and test-crates.yml assert qbz-app::listen_log lists at
least 25 tests and passes, so a refactor that drops the module's tests
fails loudly instead of shrinking coverage. Bounded: the binary is
already built by the workspace run.

cc
2026-08-28 20:30:43 -06:00
vicrodh 9b14740e32 feat(settings): Listening history toggle and clear; history read gate
Settings > Integrations > PRIVACY: a Listening history toggle (default
ON; the state lives in the per-user listen_meta, so it follows the
account) and a confirmed Clear (DELETE + VACUUM). Strings in all eight
catalogs; README names where the data lives.

ScrobblerSettings::history_read_allowed(service) = *_active(): the
scrobbling switch now governs reading a service's history for
Recommendations as well as sending to it, and its Settings text says
so. recommendations_qt uses it instead of *_is_authed().

cc
2026-08-28 20:30:43 -06:00
vicrodh 3e060d6535 feat(qbzd): listen log subscriber beside the scrobbler
A CoreEvent-bus sibling of scrobble_engine WITHOUT its enabled gate: the
log is local and on by default, so a headless streamer finally records
history. TrackStarted opens a row and closes the previous one with an
inferred reason (natural within 2 s of the end, skip otherwise — nobody
emits TrackEnded); PositionUpdated feeds the accumulator; Stopped closes
with the same inference. origin_id = qbzd:<hostname>; the shutdown path
aborts the task and closes the open row.

cc
2026-08-28 20:30:43 -06:00
vicrodh b38cb34f08 feat(qt): listen log tracker on the playback poll loop
listen_log_qt binds the per-user ListenLogger at login/restore/offline
entry and drops it at logout. Hooks: the de-duped track edge opens a row
(closing the previous as skip), every 1 Hz tick feeds the accumulator,
the natural end-of-track predicate closes as natural, the stream-failure
toast closes as error, three consecutive empty ticks are the stop, and
main.rs closes the row in flight as shutdown after the event loop (and
on the Windows session-commit path). Cast and peer QConnect stay out.
Ephemeral ids never reach the log (same guard as recently_qt).

cc
2026-08-28 20:30:43 -06:00
vicrodh ef665dcc5c feat(models): QueueTrack carries isrc/recording_mbid; Track models audio_info
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.

Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).

cc
2026-08-28 20:30:42 -06:00
vicrodh 5bd1009ca1 feat(listen-log): per-user listen event store, tracker and reading rules
New qbz-app::listen_log (contract §12.1): listen_events with PK = event
(never the track), snapshot text per row, source + native item id,
context, stream facts; listen_meta with schema_version, origin_id,
paused, retention_days (unlimited). The tracker is a pure state machine:
played_ms accumulates only while playing from monotonic deltas <= 5 s,
so pauses, seeks and stalls add nothing; flush every 10 s of play; rows
close as natural/skip/stop/error/shutdown and orphans close as shutdown
on the next open. No threshold at write time: is_play / is_skip are
pure reader rules with boundary tests. ListenLogger is the async facade
(spawn_blocking everywhere); paused writes nothing, clear is
DELETE + VACUUM + checkpoint. 29 tests.

cc
2026-08-28 20:30:00 -06:00
vicrodh 6caab7b71a feat(reco): identity guardrail replaces the genre blocklist; rank by score
- INCOMPATIBLE_GENRES / INCOMPATIBLE_TITLE_KEYWORDS removed: a global
  blocklist papered over an identity bug (name search returning a
  homonym) and punished every user's taste for it.
- artist_guardrail: ArtistLookup seam (mockable), id before text
  (qobuz:{id} nodes resolve by id; MBIDs hit the mb_qobuz_artist_match
  cache before search_artists), validation relative to the playlist
  seeds (shared level-1 genre root or a shared MusicBrainz tag), homonym
  tie-break by seed neighbourhood / label, reject when no signal exists.
- suggestions: stable sort by similarity_score desc, dedup keeping the
  higher score, THEN truncate (the shuffle threw the score away and the
  dedup kept the first hit while claiming the best).
- Eight network-free tests cover the rules; rand is now unused here.

cx
2026-08-28 20:30:00 -06:00
vicrodh 6587201a1c fix(qbzd): log loudly when the artist-vector store fails to open
The boot comment claimed suggestions work un-cached without the store;
suggestions.rs and builder.rs both answer "No active session" when it
is None. Warn on failure and say what the symptom will be.

cc
2026-08-28 19:54:47 -06:00
vicrodh 6ca219d90e fix(history): prune Most Played on folder removal; GC stale radio sessions
Removing a library folder pruned Recently Played but not
album_play_history.db, so Most Played kept cards onto deleted tracks.
radio_history had no delete path at all; sessions idle for more than
30 days (by last played_at, so a running radio is never pulled) are
dropped on open.

cc
2026-08-28 19:54:47 -06:00
vicrodh 6a99539537 fix(playlist-manager): read play counts from playlist_play_history.db
playlist_stats.play_count had no writer anywhere in the workspace
(increment_playlist_play_count: zero callers), so the badge and the
Play Count sort were 0 for everyone. Derive the counts from the store
the track-start edge actually writes; the library table is untouched.

cc
2026-08-28 19:54:47 -06:00
vicrodh ae9306988e fix(offline-cache): touch last_accessed_at when a cached track plays
OfflineCacheDb::touch had no caller, so LRU eviction ordered by the
download date and evicted the most-played tracks first. Touch in
resolve_offline_bytes, the one resolver every offline play goes through.

cc
2026-08-28 19:54:47 -06:00
vicrodh e0a9f60336 fix(scrobble): stamp started_at and time the threshold by audible position
The delayed scrobble used unix_now() when it fired (up to 240 s late on
a long track) and slept on wall-clock, so a track paused two minutes in
still scrobbled. Capture started_at on the track edge and wait on the
player's own position while it is playing: pause holds the scrobble, a
stop drops it, a newer track edge still self-cancels via the generation.
Same rule qbzd's scrobble_engine already applies.

cc
2026-08-28 19:54:47 -06:00
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 136f4d77ad merge: integrate local library Qt workflows
cx
2026-08-28 18:50:36 -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 353ed7fff8 test(windows): verify both URL protocols in MSI smoke
cx
2026-08-28 10:13:17 -06:00
vicrodh 351f9786a9 ci(storage): shorten preflight artifact retention
cx
2026-08-28 10:12:08 -06:00
vicrodh 05cc0c815f ci(windows): add fast MSI authoring preflight
cx
2026-08-28 09:44:25 -06:00
vicrodh 783dbb964e fix(windows): make MSI authoring valid on WiX 5
cx
2026-08-28 09:44:25 -06:00
vicrodh 6ad7bd05ce ci(cache): reuse qbzd builds across Linux packaging
cx
2026-08-28 09:44:25 -06:00
vicrodh d9fadfdb7c ci(cache): seed release caches from pre-release
cx
2026-08-28 09:44:25 -06:00
vicrodh 9d8af4b59d docs(ci): update qmllint parity note
cx
2026-08-28 09:44:25 -06:00
vicrodh bf7a071073 ci(packaging): add safe AUR and Gentoo dry runs
cx
2026-08-28 09:44:25 -06:00
vicrodh d987aaa798 fix(ci): preserve macdeployqt failures
cx
2026-08-28 09:44:25 -06:00
vicrodh aaf9573c57 fix(ci): harden Gentoo overlay publishing
cx
2026-08-28 09:44:25 -06:00
vicrodh ac506e4d6c fix(ci): harden AUR release publishing
cx
2026-08-28 09:44:25 -06:00
vicrodh ecd4fd05c3 fix(ci): refuse partial updater manifests
cx
2026-08-28 09:44:25 -06:00
vicrodh 3e3c5ffc2a fix(ci): allow PR retargeting from forks
cx
2026-08-28 09:44:25 -06:00
vicrodh 2a2c2fb2ea fix(ci): make Snap preflights safe and reproducible
cx
2026-08-28 09:44:25 -06:00
vicrodh dc7b1ba02b fix(ci): make macOS release failures authoritative
cx
2026-08-28 09:44:25 -06:00
vicrodh c79a8ab54f fix(ci): build requested release refs consistently
cx
2026-08-28 09:44:25 -06:00
vicrodh bf710fb7eb ci(qml): share qmllint gate across Linux and Windows
cx
2026-08-28 09:44:25 -06:00
vicrodh 9a0dbd60c2 fix(ci): use pre-OSMF WiX for Windows packaging
cx
2026-08-28 09:44:25 -06:00
vicrodh 0b7fa6da93 test(audio): honor platform support in direct-route assertions
cx
2026-08-28 09:44:25 -06:00
vicrodh bc4e8e6d7a fix(ci): make Windows preflight executable
Teach qmllint about statically registered native QML types and enforce metadata parity. Pin release builds to the MSVC 2022 runner and force Cargo to use the Visual Studio linker instead of Git for Windows link.exe.

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 6f99bd6cf9 merge: integrate backend reliability round
cx
2026-08-28 02:58:29 -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
vicrodh 975c00282b fix(snap): ship PipeWire routing tools
cx
2026-08-27 20:18:18 -06: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 6bfeb88c3f docs(qbzd): cover appliance and read-only deployment
cx
2026-08-27 19:51:03 -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 b9478a3788 merge: expand immersive visualizers
cx
2026-08-27 19:17:09 -06:00
vicrodh b1e27457e8 feat(qt): expand immersive visualizer experience
cx
2026-08-27 19:16:27 -06:00
vicrodh 79f91492bb merge: harden preferred GPU startup
cx
2026-08-27 17:55:05 -06:00
vicrodh 6e62724b4b fix(qt): preflight preferred GPU presentation
cx
2026-08-27 17:54:27 -06:00
vicrodh 7eb278ed58 ci(windows): a qt gate for Windows, on the same triggers as the releases
test-crates.yml's `qt-gate` job proves the Qt frontend on Linux: five QML
audits, the Slint-free dep-graph check, cargo test -p qbz-qt, and an offscreen
boot that fails on a QML complaint or a missing "QbzCore initialized". Windows
had no equivalent, so nothing verified it between one hand-run release and the
next.

Same gates, same complaint regex, same core-init check, so a Windows
regression produces the same shape of evidence as a Linux one.

Triggers follow the repository rule the release workflows already use:
AUTOMATIC ON A `v*` TAG ONLY, with workflow_dispatch always available and
taking an optional ref. No branch push and no pull_request: a Qt build on a
Windows runner is fifteen minutes at twice the Linux rate, and gating every
Linux contributor's PR on a platform that ships explicitly as-is would block
work nobody promised to support. On a tag it runs beside release-windows.yml
and answers a different question -- that job proves the artifact builds and
boots, this one proves the types resolve and the tests are green.

It adds one gate the Linux job does not have: qmllint. `cargo check` sees
nothing of QML, so a wrong type name or a property that does not exist compiles
perfectly and fails at RUNTIME -- and because the failing component hangs off
AppShell, the shell never instantiates and the app opens to a GREY SCREEN. That
happened during this port and reached the owner; qmllint found all three causes
in seconds. Adding it to the Linux job is a Linux change and belongs to Linux.

Two Windows-specific pieces of the smoke: long paths are enabled first, because
cxx-qt deep-copies include trees and qmlcachegen writes ~340 files under
OUT_DIR; and the run gets a private %APPDATA%/%LOCALAPPDATA% rather than the
XDG dirs and private session bus the Linux gate uses, since every store QBZ
opens derives from those and a smoke writing into the runner's real profile can
pass on what a previous run left behind.

cc
2026-08-27 17:50:47 -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 8fb02401fc ci(windows): release workflow, WiX authoring, LGPL notices
Mirrors release-macos.yml step for step: aqt MSVC Qt, cargo build -p qbz-qt,
windeployqt --qmldir, the offscreen smoke with the Qt install hidden, a WiX 7
MSI, a portable zip and the PDB. Unsigned by decision (D4).

The MSI carries the identity the app cannot give itself. Its Start-menu
shortcut sets System.AppUserModel.ID, and a matching
Software\Classes\AppUserModelId key is written beside it: Windows resolves an
unpackaged app's AppUserModelID through EITHER, and without one of them it
drops every toast without a word. The URL protocols qbz:// and qobuzapp:// are
registered the same way, the analogue of CFBundleURLSchemes.

The NASM step stays. Measured on the box: aws-lc-sys 0.39.1 uses real NASM --
its shipped prebuilt objects are only used when nasm is ABSENT -- and without
either the build fails hard with "Missing dependency: nasm". Switching rustls
to ring would drop the requirement but would change the crypto provider on
Linux and macOS too, which is a project decision and not a port's to make.

The Qt LGPL text now travels with every artifact that bundles Qt, verbatim from
gnu.org, alongside a notice naming the pinned version, the modules actually
present in a release build, where the sources are, and why relinking is a file
copy here (dynamic linking, DLLs beside the exe, no static build, no qt.conf
prefix rewrite).

Not dispatched: HARD RULE 4 makes that the owner's call.

cc
2026-08-27 17:00:09 -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